From 6864a6580d9e53ae67deacf369373dbbcce478a0 Mon Sep 17 00:00:00 2001 From: thandal Date: Wed, 22 Jul 2026 15:43:08 -0400 Subject: [PATCH 1/4] fix(server): reject browser-reachable requests on the loopback gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local gateway binds 127.0.0.1 with serverPassword === null, so isAuthorized returns true before reading a header. That is fine against the network but not against the browser: any page the user visits can issue a CORS-simple POST to a known port, and DNS rebinding makes the responses readable, exposing /models (which carries baseUrl, authType, oauthAccountId) and arbitrary inference on the user's account. Add two guards in server/auth.ts, applied in routeRequest ahead of /health so a rebound page cannot even fingerprint the server: the Host header must be a loopback literal, and any Origin header is refused. A browser can forge neither — rebinding pins Host to the attacker's own name, and fetch/XHR always attach Origin — while CLI callers send a loopback Host and no Origin, so nothing legitimate changes. Scoped to loopback binds only. Network mode binds 0.0.0.0, mandates a password, and legitimately sees LAN hostnames, so it is left to the existing gate. --- src/server/auth.ts | 35 ++++++++++++++++++++++++ src/server/router.ts | 21 ++++++++++++++- tests/server-auth.test.ts | 38 +++++++++++++++++++++++++- tests/server-router.test.ts | 54 ++++++++++++++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/server/auth.ts b/src/server/auth.ts index 84a1b304..77ae162f 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -5,6 +5,41 @@ export function sanitizeCredential(value: string | null | undefined): string | n return firstLine || null; } +/** + * A loopback-bound gateway runs unauthenticated (local mode sets serverPassword + * to null), so it is reachable by any page the user happens to visit. The two + * guards below close that off without costing legitimate CLI callers anything: + * + * - Host: a browser cannot forge it. DNS rebinding — the trick that makes + * loopback responses *readable* cross-origin — pins Host to the attacker's + * own name, so requiring a loopback literal defeats it. + * - Origin: fetch/XHR always attach it, and no clodex client is a browser, so + * its mere presence marks a CORS-simple request (text/plain POSTs skip the + * preflight and would otherwise execute blind). + * + * Network mode binds 0.0.0.0 and mandates a password, so it is gated already + * and its Host is legitimately a LAN address; both checks are skipped there. + */ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +export function isLoopbackBind(host: string | undefined): boolean { + return host === undefined || LOOPBACK_HOSTNAMES.has(host.toLowerCase()); +} + +export function isAllowedGatewayHost(hostHeader: string | null | undefined): boolean { + // HTTP/1.0 clients may omit Host entirely; browsers are incapable of it. + if (!hostHeader) return true; + try { + return LOOPBACK_HOSTNAMES.has(new URL(`http://${hostHeader}`).hostname.toLowerCase()); + } catch { + return false; + } +} + +export function isBrowserOriginRequest(originHeader: string | null | undefined): boolean { + return typeof originHeader === 'string' && originHeader.trim() !== ''; +} + export function isAuthorized(request: Request, serverPassword: string | null): boolean { if (serverPassword === null) return true; diff --git a/src/server/router.ts b/src/server/router.ts index 9658e374..842ced37 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -1,6 +1,11 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { isAuthorized } from './auth.js'; +import { + isAllowedGatewayHost, + isAuthorized, + isBrowserOriginRequest, + isLoopbackBind, +} from './auth.js'; import { formatGatewayAnthropicModels, formatOpenAIModels, @@ -183,6 +188,20 @@ async function routeRequest(req: IncomingMessage, res: ServerResponse, options: const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname; plog(`${req.method} ${pathname}`); + // Runs ahead of /health so that a rebound page cannot even probe for us. + if (isLoopbackBind(options.host)) { + if (!isAllowedGatewayHost(req.headers.host)) { + plog(`blocked non-loopback Host header: ${req.headers.host}`); + sendJson(res, 403, { error: { message: 'Forbidden: unexpected Host header' } }); + return; + } + if (isBrowserOriginRequest(req.headers.origin)) { + plog(`blocked browser request from Origin: ${req.headers.origin}`); + sendJson(res, 403, { error: { message: 'Forbidden: browser origins are not accepted' } }); + return; + } + } + if (req.method === 'GET' && pathname === '/health') { sendJson(res, 200, { ok: true }); return; diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 71dafa15..c1dc3231 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { extractBearerToken, isAuthorized, sanitizeCredential } from '../src/server/auth.js'; +import { + extractBearerToken, + isAllowedGatewayHost, + isAuthorized, + isBrowserOriginRequest, + isLoopbackBind, + sanitizeCredential, +} from '../src/server/auth.js'; function request(headers: Record = {}): Request { return new Request('http://localhost/test', { headers }); @@ -32,3 +39,32 @@ describe('server auth', () => { expect(isAuthorized(request({ authorization: 'Bearer secret' }), 'secret')).toBe(true); }); }); + +describe('loopback gateway browser guards', () => { + it('recognises which binds are loopback', () => { + expect(isLoopbackBind('127.0.0.1')).toBe(true); + expect(isLoopbackBind('::1')).toBe(true); + expect(isLoopbackBind(undefined)).toBe(true); // fail closed + expect(isLoopbackBind('0.0.0.0')).toBe(false); // network mode is password-gated + }); + + it('accepts loopback Host headers with and without a port', () => { + for (const host of ['127.0.0.1:17645', 'localhost:17645', '[::1]:17645', '127.0.0.1', 'LocalHost']) { + expect(isAllowedGatewayHost(host), host).toBe(true); + } + expect(isAllowedGatewayHost(undefined)).toBe(true); // HTTP/1.0 has no Host + }); + + it('rejects the rebound Host headers a DNS-rebinding page is pinned to', () => { + for (const host of ['attacker.example:17645', 'clodex.attacker.test', '127.0.0.1.nip.io:17645', 'not a host']) { + expect(isAllowedGatewayHost(host), host).toBe(false); + } + }); + + it('flags any Origin header as a browser request', () => { + expect(isBrowserOriginRequest('https://evil.example')).toBe(true); + expect(isBrowserOriginRequest('null')).toBe(true); // sandboxed iframe + expect(isBrowserOriginRequest(undefined)).toBe(false); + expect(isBrowserOriginRequest(' ')).toBe(false); + }); +}); diff --git a/tests/server-router.test.ts b/tests/server-router.test.ts index be51a596..f5ca2c46 100644 --- a/tests/server-router.test.ts +++ b/tests/server-router.test.ts @@ -1,4 +1,4 @@ -import { createServer, type Server } from 'node:http'; +import { createServer, request as httpRequest, type Server } from 'node:http'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -147,6 +147,58 @@ afterEach(async () => { } }); +/** + * fetch() refuses to set Host, so rebinding has to be simulated at the raw + * HTTP layer — which is exactly what a rebound browser connection looks like. + */ +function rawRequest( + port: number, + path: string, + headers: Record, + method = 'GET', +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = httpRequest({ host: '127.0.0.1', port, path, method, headers }, res => { + let body = ''; + res.on('data', chunk => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }); + req.on('error', reject); + req.end(); + }); +} + +describe('server router browser guards', () => { + it('serves loopback callers that send no Origin', async () => { + const handle = await startTestServer(); + expect((await rawRequest(handle.port, '/health', { Host: `127.0.0.1:${handle.port}` })).status).toBe(200); + }); + + it('rejects a rebound Host before it can even probe /health', async () => { + const handle = await startTestServer(); + const res = await rawRequest(handle.port, '/health', { Host: 'attacker.example' }); + expect(res.status).toBe(403); + expect(JSON.parse(res.body).error.message).toContain('Host'); + }); + + it('rejects a CORS-simple POST from a page the user is visiting', async () => { + const handle = await startTestServer(); + const res = await fetch(`${handle.url}/anthropic/v1/messages`, { + method: 'POST', + headers: { origin: 'https://evil.example', 'content-type': 'text/plain' }, + body: JSON.stringify({ model: 'claude-native', messages: [{ role: 'user', content: 'hi' }] }), + }); + expect(res.status).toBe(403); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + }); + + it('leaves network-mode binds to the password gate', async () => { + const handle = await startTestServer({ host: '0.0.0.0', serverPassword: 'hunter2' }); + const res = await rawRequest(handle.port, '/models', { Host: 'lan-box.local', 'x-api-key': 'hunter2' }); + expect(res.status).toBe(200); + }); +}); + describe('server router', () => { it('logs inference routing metadata without request content', async () => { const dir = mkdtempSync(join(tmpdir(), 'clodex-server-audit-')); From c07b8859ad0fa308da34e00782bb46ebb9861ca1 Mon Sep 17 00:00:00 2001 From: thandal Date: Wed, 22 Jul 2026 15:43:58 -0400 Subject: [PATCH 2/4] fix(upstream): stop following provider redirects that carry credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relayAnthropicMessages called fetch with default redirect handling. anthropicUpstreamHeaders attaches the credential as x-api-key, and the fetch spec only strips authorization on a cross-origin redirect — a custom header survives — so a provider answering 302 received the API key plus the full request body. fetch-template-models and custom-endpoint already got this right; the inference path and registry/refresh-models did not. Set redirect: 'manual' on both and fail the 3xx explicitly with a 502 rather than relaying a bare redirect status to the client. --- src/registry/refresh-models.ts | 3 +++ src/upstream-forward.ts | 14 ++++++++++ tests/upstream-forward.test.ts | 49 ++++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/registry/refresh-models.ts b/src/registry/refresh-models.ts index c68af397..c35ede55 100644 --- a/src/registry/refresh-models.ts +++ b/src/registry/refresh-models.ts @@ -153,6 +153,9 @@ async function fetchJsonWithAuth( Authorization: `Bearer ${accessToken}`, 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, + // Match fetch-template-models/custom-endpoint: a bearer token is attached, + // so a redirect must fail rather than be chased to an unvetted host. + redirect: 'manual', signal: controller.signal, }).finally(() => clearTimeout(timer)); if (!response.ok) { diff --git a/src/upstream-forward.ts b/src/upstream-forward.ts index f5a7d1e8..10a95be0 100644 --- a/src/upstream-forward.ts +++ b/src/upstream-forward.ts @@ -87,6 +87,11 @@ export async function relayAnthropicMessages( options.extraHeaders, ), body: JSON.stringify(body), + // Never auto-follow: anthropicUpstreamHeaders() attaches the credential as + // `x-api-key`, and the fetch spec only strips `authorization` on a + // cross-origin redirect — a custom header survives. Following a provider's + // 3xx would hand that key and the full request body to the redirect target. + redirect: 'manual', signal: options.signal, }); @@ -99,6 +104,15 @@ export async function relayAnthropicMessages( throw new UpstreamUnreachableError(err); } + if (upstreamRes.status >= 300 && upstreamRes.status < 400) { + const location = upstreamRes.headers.get('location') ?? '(none)'; + options.log?.(`anthropic upstream refused redirect to ${location}`); + options.onUpstreamError?.(upstreamRes.status, `redirect to ${location}`); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: 'Upstream attempted a redirect; refusing to forward credentials' } })); + return; + } + if (!upstreamRes.ok) { const errBody = await upstreamRes.text(); options.log?.(`anthropic upstream ${upstreamRes.status}: ${errBody}`); diff --git a/tests/upstream-forward.test.ts b/tests/upstream-forward.test.ts index 2e7f0cb5..c08354c6 100644 --- a/tests/upstream-forward.test.ts +++ b/tests/upstream-forward.test.ts @@ -1,6 +1,27 @@ // tests/upstream-forward.test.ts -import { describe, it, expect, vi } from 'vitest'; -import { anthropicUpstreamHeaders, fetchWithOAuthRetry } from '../src/upstream-forward.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createServer, type Server, type RequestListener } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { anthropicUpstreamHeaders, fetchWithOAuthRetry, relayAnthropicMessages } from '../src/upstream-forward.js'; + +const servers: Server[] = []; + +function listen(handler: RequestListener): Promise { + const server = createServer(handler); + servers.push(server); + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`); + }); + }); +} + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + if (server) await new Promise(resolve => server.close(resolve)); + } +}); describe('anthropicUpstreamHeaders', () => { it('includes bearer and x-api-key', () => { @@ -47,3 +68,27 @@ describe('fetchWithOAuthRetry', () => { expect(request).toHaveBeenNthCalledWith(2, 'new-token'); }); }); + +describe('relayAnthropicMessages redirect handling', () => { + it('refuses an upstream redirect instead of replaying the key to its target', async () => { + const sink = vi.fn((_req, res) => { res.writeHead(200); res.end('{}'); }); + const sinkUrl = await listen(sink); + + const upstreamUrl = await listen((_req, res) => { + res.writeHead(302, { location: `${sinkUrl}/v1/messages` }); + res.end(); + }); + + const gatewayUrl = await listen((_req, res) => { + void relayAnthropicMessages(res, `${upstreamUrl}/v1/messages`, { model: 'm' }, 'secret-key', false); + }); + + const res = await fetch(gatewayUrl, { method: 'POST' }); + + expect(res.status).toBe(502); + await expect(res.json()).resolves.toMatchObject({ + error: { message: expect.stringContaining('redirect') }, + }); + expect(sink).not.toHaveBeenCalled(); + }); +}); From b4946861e618ffd78aef2e86a65925a66a8928aa Mon Sep 17 00:00:00 2001 From: thandal Date: Wed, 22 Jul 2026 15:44:48 -0400 Subject: [PATCH 3/4] fix(patch): treat $-sequences in model ids as literal text extendAliasArray passed its payload to String.replace as a string, so $&/$`/$' inside it were re-read as insertion patterns after JSON.stringify had escaped them. Aliases are validated by regex; the canonical model ids they fall back to are not. A $` in an id splices the whole array prefix back into the literal and leaves the patched Claude Code bundle unparseable. Use a function replacement, which is not pattern-expanded. --- src/patch-transforms.ts | 7 ++++++- tests/patcher.test.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/patch-transforms.ts b/src/patch-transforms.ts index 277fa5e4..1408f8d6 100644 --- a/src/patch-transforms.ts +++ b/src/patch-transforms.ts @@ -186,7 +186,12 @@ export function applyClodexPatches(source: string, config: PatchScriptModelConfi function extendAliasArray(arrLiteral: string): string { const toAdd = IDENTITIES.filter((a) => !new RegExp('"' + reEsc(a) + '"').test(arrLiteral)); if (toAdd.length === 0) return arrLiteral; // idempotent - return arrLiteral.replace(/\]\s*$/, ',' + toAdd.map(q).join(',') + ']'); + // Function replacement, not a string one: a string replacement re-reads + // `$&`/`` $` ``/`$'` in the payload as insertion patterns, which would undo + // q()'s escaping. Identity aliases are validated upstream but the canonical + // model ids they are built from are not. + const addition = ',' + toAdd.map(q).join(',') + ']'; + return arrLiteral.replace(/\]\s*$/, () => addition); } // --------------------------------------------------------------------------- diff --git a/tests/patcher.test.ts b/tests/patcher.test.ts index 478ded69..9487f5ae 100644 --- a/tests/patcher.test.ts +++ b/tests/patcher.test.ts @@ -264,6 +264,20 @@ describe('patch script identity naming', () => { 'clodex:openai:mystery': { context: 128_000, display: 'Mystery (OpenAI)' }, }; + it('treats $-sequences in a model id as literal text, not replacement patterns', () => { + // Aliases are validated, canonical ids are not. `$\`` expands to everything + // before the match when handed to String.replace as a *string*, which would + // splice the whole array prefix back into the literal and leave the patched + // bundle unparseable. + const out = runPatchScript({ 'clodex:openai:ev$`il': { context: 1000, display: 'Evil' } }); + + expect(out).toContain('"clodex:openai:ev$`il"'); + expect(out).toContain('.enum(["sonnet","opus","haiku","fable","clodex:openai:ev$`il"])'); + const knownList = /var KNOWN=(\[.*?\]);/.exec(out)?.[1]; + expect(() => JSON.parse(knownList!)).not.toThrow(); + expect(JSON.parse(knownList!)).toContain('clodex:openai:ev$`il'); + }); + it('injects the ALIAS — not the canonical id — as the model identity', () => { const out = runPatchScript(config); From 3ce7ced467689a3a243421ad3bec5381614a627a Mon Sep 17 00:00:00 2001 From: thandal Date: Sat, 25 Jul 2026 11:03:08 -0400 Subject: [PATCH 4/4] fix(response): responding to the review requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1. OAuth redirect protection - Added redirect: 'manual' to shared refresh-token POSTs at src/oauth/refresh-http.ts:39. - Added it to all OpenAI device authorization, polling, and token-exchange POSTs at src/oauth/openai.ts:57, src/oauth/openai.ts:90, and src/oauth/openai.ts:105. - Failed OAuth response bodies are canceled to release transport resources at src/oauth/openai.ts:13. - 2. Origin guard narrowed - Loopback HTTP(S) origins and custom application schemes such as Electron’s app:// are now accepted. - Only HTTP(S) origins with non-loopback hosts are rejected at src/server/auth.ts:39. - Router logging and 403 messages reflect the narrower rule at src/server/router.ts:219. - 3. Documentation updated - Documented loopback gateway restrictions and compatible clients at README.md:156. - Recorded the Host/Origin security invariant at CLAUDE.md:100. - Extra stuff that came up - Redirect responses from Anthropic upstreams now have their bodies canceled at src/upstream-forward.ts:146. - Added an assertion covering the existing model-refresh redirect: 'manual' behavior at tests/registry-refresh-models.test.ts:62. - Expanded regression coverage for OAuth redirects and Electron/loopback origins. --- CLAUDE.md | 2 +- README.md | 4 ++-- src/oauth/openai.ts | 14 ++++++++++++++ src/oauth/refresh-http.ts | 3 +++ src/server/auth.ts | 17 ++++++++++++----- src/server/router.ts | 8 ++++---- src/upstream-forward.ts | 5 +++++ tests/oauth-openai.test.ts | 5 ++++- tests/oauth.test.ts | 17 +++++++++++------ tests/registry-refresh-models.test.ts | 5 ++++- tests/server-auth.test.ts | 22 ++++++++++++++++------ tests/server-router.test.ts | 11 +++++++++++ 12 files changed, 87 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe9f043f..84e9cc76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ Registry writes use atomic hard-link lock publication, so the filesystem contain - Context is omitted from the patch map when unknown or equal to Claude Code's 200k default; `[1m]`-suffixed model ids and explicit context are mutually exclusive in the patch transforms. - The per-site transforms in `patch-transforms.ts` (regexes, replacements, ordering, SKIP/FAIL semantics) are hard-won — change them only with byte-for-byte equivalence evidence on a real binary. -**Server** (`src/server/`): `index.ts` loads models from the registry (`loadServerModels`), `router.ts` handles `/anthropic` (Anthropic-format; passthrough for `modelFormat:'anthropic'` with `baseUrl`, SDK adapter for `'openai'`) and `/openai/v1` (OpenAI-format via `src/openai-adapter.ts`). Wizard/quick-start settings persist to config; network mode requires a password; default port 17645 (`--port` overrides). Endpoint-mode request model resolution (`createGatewayModelCatalog` in `server/models.ts`) accepts, in precedence order: exact catalog id (and its gateway-discovery id) → unmasked gateway id when `--mask-gateway-ids` is on (`vendor-mask.ts`) → canonical `clodex:{provider}:{model}` id → saved short aliases from `clodex models --alias` (the same alias table the proxy-mode MITM resolves) → 400. Aliases and canonical ids are accepted INPUT only — `/models` listings still advertise exactly the canonical/masked ids. Echo invariant: an aliased request's response `model` field echoes the alias verbatim (even under masking) so a patched Claude Code's context-window lookup keys match (`aliasNames` in `ServerOptions`). +**Server** (`src/server/`): `index.ts` loads models from the registry (`loadServerModels`), `router.ts` handles `/anthropic` (Anthropic-format; passthrough for `modelFormat:'anthropic'` with `baseUrl`, SDK adapter for `'openai'`) and `/openai/v1` (OpenAI-format via `src/openai-adapter.ts`). Wizard/quick-start settings persist to config; network mode requires a password; default port 17645 (`--port` overrides). Loopback binds reject non-loopback `Host` headers and non-loopback HTTP(S) origins before `/health`; loopback HTTP(S) and custom application origins such as Electron's `app://` remain accepted. Network mode skips these loopback guards and relies on its password. Endpoint-mode request model resolution (`createGatewayModelCatalog` in `server/models.ts`) accepts, in precedence order: exact catalog id (and its gateway-discovery id) → unmasked gateway id when `--mask-gateway-ids` is on (`vendor-mask.ts`) → canonical `clodex:{provider}:{model}` id → saved short aliases from `clodex models --alias` (the same alias table the proxy-mode MITM resolves) → 400. Aliases and canonical ids are accepted INPUT only — `/models` listings still advertise exactly the canonical/masked ids. Echo invariant: an aliased request's response `model` field echoes the alias verbatim (even under masking) so a patched Claude Code's context-window lookup keys match (`aliasNames` in `ServerOptions`). **Env isolation** (`src/env.ts`): `buildChildEnv()` copies `process.env`, deletes conflicting `ANTHROPIC_*`/related vars, sets `ANTHROPIC_BASE_URL`/`ANTHROPIC_API_KEY`/`ANTHROPIC_MODEL` for the child only. Claude Code may persist the model to `~/.claude/settings.json` itself; outside clodex's control (reset with `claude --model sonnet`). diff --git a/README.md b/README.md index f1524edc..176a5a45 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,9 @@ ANTHROPIC_BASE_URL=http://127.0.0.1:17645/anthropic OPENAI_BASE_URL=http://127.0.0.1:17645/openai/v1 ``` -Use any API key locally; network mode requires the server password. Proxy mode prints `HTTPS_PROXY`, `HTTP_PROXY`, `NODE_EXTRA_CA_CERTS`, and adjusted `NO_PROXY` / `no_proxy` values to export. The adjusted bypass lists preserve unrelated hosts while ensuring `api.anthropic.com` reaches the selective proxy. Do **not** set `ANTHROPIC_BASE_URL` in that mode. +Use any API key locally; network mode requires the server password. A loopback endpoint accepts CLI callers, loopback HTTP(S) origins, and application origins such as Electron's `app://`, but returns 403 for non-loopback `Host` headers or HTTP(S) browser origins. Proxy mode prints `HTTPS_PROXY`, `HTTP_PROXY`, `NODE_EXTRA_CA_CERTS`, and adjusted `NO_PROXY` / `no_proxy` values to export. The adjusted bypass lists preserve unrelated hosts while ensuring `api.anthropic.com` reaches the selective proxy. Do **not** set `ANTHROPIC_BASE_URL` in that mode. -Several `clodex server` instances can run at once — each advertises itself in `~/.clodex/server-runtime.json`, and `clodex-claude` prefers a proxy-mode server (newest first) when bridging (see [docs/background-agents.md](docs/background-agents.md)). Pass `--no-discovery` to keep a server out of that file, e.g. a dedicated endpoint you point another tool at. +Several `clodex server` instances can run at once — each advertises itself in `~/.clodex/server-runtime.json`, and `clodex-claude` prefers a proxy-mode server (newest first) when bridging (see [docs/background-agents.md](docs/background-agents.md)). Pass `--no-discovery` to keep a server out of that file, e.g. a dedicated endpoint for a compatible local tool. Examples: diff --git a/src/oauth/openai.ts b/src/oauth/openai.ts index 9904ee0e..122603a3 100644 --- a/src/oauth/openai.ts +++ b/src/oauth/openai.ts @@ -10,6 +10,14 @@ const ISSUER = 'https://auth.openai.com'; const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000; const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000; +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // Preserve the OAuth failure when transport cleanup also fails. + } +} + export interface OpenAiIdTokenClaims { chatgpt_account_id?: string; organizations?: Array<{ id: string }>; @@ -46,8 +54,10 @@ export async function requestOpenAiDeviceCode(): Promise { 'User-Agent': `clodex/${VERSION}`, }, body: JSON.stringify({ client_id: CLIENT_ID }), + redirect: 'manual', }); if (!response.ok) { + await cancelResponseBody(response); throw new Error('Failed to initiate OpenAI device authorization'); } return response.json() as Promise; @@ -77,6 +87,7 @@ export async function pollOpenAiDeviceCodeToken( device_auth_id: deviceData.device_auth_id, user_code: deviceData.user_code, }), + redirect: 'manual', }); if (response.ok) { @@ -91,14 +102,17 @@ export async function pollOpenAiDeviceCodeToken( client_id: CLIENT_ID, code_verifier: data.code_verifier, }).toString(), + redirect: 'manual', }); if (!tokenResponse.ok) { + await cancelResponseBody(tokenResponse); throw new Error(`OpenAI token exchange failed (${tokenResponse.status})`); } const tokens = await tokenResponse.json() as OAuthTokenResponse; return { tokens, accountId: extractOpenAiAccountId(tokens) }; } + await cancelResponseBody(response); if (response.status !== 403 && response.status !== 404) { throw new Error(`OpenAI device authorization failed (${response.status})`); } diff --git a/src/oauth/refresh-http.ts b/src/oauth/refresh-http.ts index 1f2d13e6..4f2b5d28 100644 --- a/src/oauth/refresh-http.ts +++ b/src/oauth/refresh-http.ts @@ -34,6 +34,9 @@ export async function postOAuthRefresh( ...options.headers, }, body: isJson ? JSON.stringify(body) : (body as URLSearchParams).toString(), + // Refresh tokens are carried in the request body, so never replay them to + // a redirect target. + redirect: 'manual', }); if (!response.ok) { diff --git a/src/server/auth.ts b/src/server/auth.ts index 77ae162f..15cf051c 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -13,9 +13,9 @@ export function sanitizeCredential(value: string | null | undefined): string | n * - Host: a browser cannot forge it. DNS rebinding — the trick that makes * loopback responses *readable* cross-origin — pins Host to the attacker's * own name, so requiring a loopback literal defeats it. - * - Origin: fetch/XHR always attach it, and no clodex client is a browser, so - * its mere presence marks a CORS-simple request (text/plain POSTs skip the - * preflight and would otherwise execute blind). + * - Origin: reject web pages hosted away from loopback, including CORS-simple + * text/plain POSTs that skip preflight. Loopback web apps and Electron clients + * with custom-scheme origins remain valid local callers. * * Network mode binds 0.0.0.0 and mandates a password, so it is gated already * and its Host is legitimately a LAN address; both checks are skipped there. @@ -36,8 +36,15 @@ export function isAllowedGatewayHost(hostHeader: string | null | undefined): boo } } -export function isBrowserOriginRequest(originHeader: string | null | undefined): boolean { - return typeof originHeader === 'string' && originHeader.trim() !== ''; +export function isDisallowedGatewayOrigin(originHeader: string | null | undefined): boolean { + if (typeof originHeader !== 'string' || originHeader.trim() === '') return false; + try { + const origin = new URL(originHeader); + if (origin.protocol !== 'http:' && origin.protocol !== 'https:') return false; + return !LOOPBACK_HOSTNAMES.has(origin.hostname.toLowerCase()); + } catch { + return false; + } } export function isAuthorized(request: Request, serverPassword: string | null): boolean { diff --git a/src/server/router.ts b/src/server/router.ts index 65b0a00f..0cb42080 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { isAllowedGatewayHost, isAuthorized, - isBrowserOriginRequest, + isDisallowedGatewayOrigin, isLoopbackBind, } from './auth.js'; import { @@ -216,9 +216,9 @@ async function routeRequest(req: IncomingMessage, res: ServerResponse, options: sendJson(res, 403, { error: { message: 'Forbidden: unexpected Host header' } }); return; } - if (isBrowserOriginRequest(req.headers.origin)) { - plog(`blocked browser request from Origin: ${req.headers.origin}`); - sendJson(res, 403, { error: { message: 'Forbidden: browser origins are not accepted' } }); + if (isDisallowedGatewayOrigin(req.headers.origin)) { + plog(`blocked non-loopback browser Origin: ${req.headers.origin}`); + sendJson(res, 403, { error: { message: 'Forbidden: non-loopback browser origins are not accepted' } }); return; } } diff --git a/src/upstream-forward.ts b/src/upstream-forward.ts index b2917478..1f1305b6 100644 --- a/src/upstream-forward.ts +++ b/src/upstream-forward.ts @@ -145,6 +145,11 @@ export async function relayAnthropicMessages( if (upstreamRes.status >= 300 && upstreamRes.status < 400) { const location = upstreamRes.headers.get('location') ?? '(none)'; + try { + await upstreamRes.body?.cancel(); + } catch { + // Preserve the redirect refusal when transport cleanup also fails. + } options.log?.(`anthropic upstream refused redirect to ${location}`); options.onUpstreamError?.(upstreamRes.status, `redirect to ${location}`); res.writeHead(502, { 'Content-Type': 'application/json' }); diff --git a/tests/oauth-openai.test.ts b/tests/oauth-openai.test.ts index 41db6296..465dec64 100644 --- a/tests/oauth-openai.test.ts +++ b/tests/oauth-openai.test.ts @@ -58,7 +58,7 @@ describe('oauth/openai', () => { expect(res.access_token).toBe('new_token'); expect(global.fetch).toHaveBeenCalledWith( 'https://auth.openai.com/oauth/token', - expect.objectContaining({ method: 'POST' }), + expect.objectContaining({ method: 'POST', redirect: 'manual' }), ); }); @@ -121,6 +121,9 @@ describe('oauth/openai', () => { }); expect(sleep).toHaveBeenCalledWith(expect.any(Number)); // Called after the 403 expect(result.tokens.access_token).toBe('final_access_token'); + for (const [, init] of vi.mocked(global.fetch).mock.calls) { + expect(init).toEqual(expect.objectContaining({ redirect: 'manual' })); + } }); it('throws if device initiation fails', async () => { diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 7332414b..cfe7ccb1 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -52,12 +52,13 @@ describe('oauth refresh http', () => { vi.restoreAllMocks(); }); - it('posts form refresh requests and includes response text in the error', async () => { - vi.stubGlobal('fetch', vi.fn(async () => ({ + it('posts form refresh requests without following redirects and includes response text in the error', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 401, text: async () => 'bad refresh', - }))); + })); + vi.stubGlobal('fetch', fetchMock); await expect(postOAuthRefresh( 'https://auth/token', @@ -69,14 +70,18 @@ describe('oauth refresh http', () => { includeBody: true, }, )).rejects.toThrow('xAI token refresh failed (401): bad refresh'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://auth/token', + expect.objectContaining({ redirect: 'manual' }), + ); }); - it('cancels an unread failed response body when error details are disabled', async () => { + it('rejects and cancels an unread redirect response', async () => { const cancel = vi.fn(async () => {}); const text = vi.fn(async () => 'must stay unread'); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, - status: 401, + status: 307, body: { cancel }, text, }))); @@ -89,7 +94,7 @@ describe('oauth refresh http', () => { errorPrefix: 'token refresh failed', includeStatus: true, }, - )).rejects.toThrow('token refresh failed (401)'); + )).rejects.toThrow('token refresh failed (307)'); expect(cancel).toHaveBeenCalledOnce(); expect(text).not.toHaveBeenCalled(); }); diff --git a/tests/registry-refresh-models.test.ts b/tests/registry-refresh-models.test.ts index 1d768b8a..59abb0ba 100644 --- a/tests/registry-refresh-models.test.ts +++ b/tests/registry-refresh-models.test.ts @@ -57,7 +57,10 @@ describe('registry/refresh-models', () => { const result = await refreshProviderModels('openai-oauth', 'mock_token', mockRegistry); expect(global.fetch).toHaveBeenCalledTimes(1); - expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('https://chatgpt.com/backend-api/codex/models?client_version='), expect.anything()); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://chatgpt.com/backend-api/codex/models?client_version='), + expect.objectContaining({ redirect: 'manual' }), + ); expect(result.ok).toBe(true); expect(result.modelCount).toBe(1); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index c1dc3231..1ee53c27 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -3,7 +3,7 @@ import { extractBearerToken, isAllowedGatewayHost, isAuthorized, - isBrowserOriginRequest, + isDisallowedGatewayOrigin, isLoopbackBind, sanitizeCredential, } from '../src/server/auth.js'; @@ -61,10 +61,20 @@ describe('loopback gateway browser guards', () => { } }); - it('flags any Origin header as a browser request', () => { - expect(isBrowserOriginRequest('https://evil.example')).toBe(true); - expect(isBrowserOriginRequest('null')).toBe(true); // sandboxed iframe - expect(isBrowserOriginRequest(undefined)).toBe(false); - expect(isBrowserOriginRequest(' ')).toBe(false); + it('rejects only non-loopback HTTP(S) origins', () => { + for (const origin of ['https://evil.example', 'http://attacker.test:17645']) { + expect(isDisallowedGatewayOrigin(origin), origin).toBe(true); + } + for (const origin of [ + 'http://localhost:17645', + 'https://127.0.0.1', + 'http://[::1]:17645', + 'app://claude-desktop', + 'null', + ]) { + expect(isDisallowedGatewayOrigin(origin), origin).toBe(false); + } + expect(isDisallowedGatewayOrigin(undefined)).toBe(false); + expect(isDisallowedGatewayOrigin(' ')).toBe(false); }); }); diff --git a/tests/server-router.test.ts b/tests/server-router.test.ts index a5064c05..d40f4844 100644 --- a/tests/server-router.test.ts +++ b/tests/server-router.test.ts @@ -228,6 +228,17 @@ describe('server router browser guards', () => { expect((await rawRequest(handle.port, '/health', { Host: `127.0.0.1:${handle.port}` })).status).toBe(200); }); + it('serves Electron and loopback-web callers', async () => { + const handle = await startTestServer(); + for (const origin of ['app://claude-desktop', `http://127.0.0.1:${handle.port}`]) { + const res = await rawRequest(handle.port, '/health', { + Host: `127.0.0.1:${handle.port}`, + Origin: origin, + }); + expect(res.status, origin).toBe(200); + } + }); + it('rejects a rebound Host before it can even probe /health', async () => { const handle = await startTestServer(); const res = await rawRequest(handle.port, '/health', { Host: 'attacker.example' });