diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 376bc472166..673898b12fd 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -266,6 +266,11 @@ picker lists Anthropic's own models until the removal succeeds. OpenCodex rememb still needs removal and retries on the next restart; `ocx claude desktop picker status` shows the picker as unavailable meanwhile. +Picker mode allows up to 64 KiB of headers on incoming requests and ordinary HTTP +responses, preserving browser session cookies. Larger upstream response headers return +502 and log `upstream:headers-too-large`, without cookie values or request paths. +Upgraded connections continue to relay bytes directly after the request handshake. + While picker mode is on, Claude Desktop reaches the network through OpenCodex. If OpenCodex stops, Desktop is offline until you fully restart it or turn picker mode off. Check the state with `ocx claude desktop picker status`; use `ocx claude desktop picker trust` to repeat the trust step, diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index 84ba8737e57..dbe169f4805 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -23,6 +23,10 @@ export interface PickerListenerOptions { } export interface PickerListenerHandle { port: number; close(): Promise } +// Browser session cookies can exceed the HTTP compatibility layer's 16 KiB default. +// Keep both sides bounded, while allowing ordinary desktop session headers through. +export const PICKER_MAX_HEADER_BYTES = 64 * 1024; + const HOP_HEADERS = new Set([ "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", @@ -65,7 +69,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" }; const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES; const upgrades = new Set(); - const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"] }); + const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"], maxHeaderSize: PICKER_MAX_HEADER_BYTES }); server.on("request", (req: IncomingMessage, res: ServerResponse) => { const method = req.method ?? "GET"; @@ -87,7 +91,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const upReq = httpsRequest({ host: upstream.host, port: upstream.port, servername: upstream.servername, ca: upstream.ca, rejectUnauthorized: true, agent: false, - method, path: req.url, headers, + method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES, }, upRes => { const status = upRes.statusCode ?? 502; const originalHeaders = filteredHeaders(upRes.rawHeaders); @@ -132,7 +136,12 @@ export async function startPickerListener(options: PickerListenerOptions): Promi res.end(rewritten ?? original); }); }); - upReq.on("error", fail); + upReq.on("error", error => { + if ((error as NodeJS.ErrnoException).code === "HPE_HEADER_OVERFLOW") { + options.log?.(`picker ${method} ${category} upstream:headers-too-large`); + } + fail(); + }); req.on("error", () => upReq.destroy()); res.on("close", () => { if (!res.writableEnded) upReq.destroy(); }); req.pipe(upReq); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 7ab2c98736d..4d7207d5062 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -198,7 +198,9 @@ The User-Agent is a routing hint, not a trust boundary: a client that fakes it r any local process already reaches (the `api.anthropic.com` intercept is on the Claude Code proxy too; the `claude.ai` relay verifies upstream and adds no credential) and breaks only its own TLS, because each terminator presents a certificate only its intended client trusts. `claude.ai:443` is -terminated by a `node:https` HTTP/1.1 relay (`picker-listener.ts`) only while the runtime's cached +terminated by a `node:https` HTTP/1.1 relay (`picker-listener.ts`) with a bounded 64 KiB +incoming-request and ordinary upstream-response header allowance for browser session cookies, +only while the runtime's cached decision is armed: macOS, persisted resolved Desktop mode first-party, Desktop intent on, `claudeCode.intercept.picker !== false`, no disarm latch, listener up, and the current picker CA trusted in the login keychain (`picker-trust.ts`). The picker CA (`picker-ca.ts`) carries critical @@ -242,8 +244,12 @@ remote `ccr` (`picker-bootstrap.ts`), failing open to the original bytes; the mo comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. Picker aliases carry `[1m]` only for authoritative windows of at least 1M, using the shared context marker helper with auto-context disabled. Sub-million opt-ins remain unmarked because the picker cannot guarantee the Desktop runner's compaction environment. A CONNECT to claude.ai that arrives before the first refresh waits at most 3 s, then goes blind. A picker proxy bind failure only disables picker mode; a picker construction or start failure closes -every socket the start had bound before rethrowing. Nothing is logged but method, bootstrap or -other, and status. +every socket the start had bound before rethrowing. Ordinary session cookies within the header +allowance relay unchanged. Upstream header overflow returns an empty 502 and logs the fixed +reason `upstream:headers-too-large`; other records contain only method, bootstrap or other, +status, and fixed bootstrap rewrite outcomes. Header values and request paths are not logged. +Upgraded connections retain raw TLS relay semantics; their upstream bytes do not pass through +the ordinary HTTP response parser. ### Picker catalog rewrite bounds diff --git a/tests/claude-integration/claude-picker-listener.test.ts b/tests/claude-integration/claude-picker-listener.test.ts index 8cfccc1bf8d..76fd7de51b1 100644 --- a/tests/claude-integration/claude-picker-listener.test.ts +++ b/tests/claude-integration/claude-picker-listener.test.ts @@ -3,11 +3,15 @@ import { createServer, request } from "node:https"; import { connect as tlsConnect } from "node:tls"; import { gzipSync } from "node:zlib"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; -import { startPickerListener } from "../../src/claude/intercept/picker-listener"; +import { PICKER_MAX_HEADER_BYTES, startPickerListener } from "../../src/claude/intercept/picker-listener"; import type { PickerListenerHandle } from "../../src/claude/intercept/picker-listener"; -import type { Server as HttpsServer } from "node:https"; +import type { TLSSocket } from "node:tls"; +import type { Duplex } from "node:stream"; import type { IncomingMessage } from "node:http"; +const HEADER_LIMIT = 64 * 1024; +const largeCookie = "synthetic-session=" + "x".repeat(24 * 1024); +const binary = Buffer.from([0, 255, 128, 13, 10, 0, 195, 40]); const models = [{ id: "ocx-model", name: "Routed" }]; const bootstrap = Buffer.from(JSON.stringify({ model_selector_config: [ { id: "code", models: [{ id: "claude-native", name: "Native", section: "main" }] }, @@ -23,7 +27,12 @@ async function fixture( const upstreamCa = options.untrusted ? createLocalInterceptCa() : ca; const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); const upstreamLeaf = issueLocalInterceptLeaf(upstreamCa, ["claude.ai"]); - const upstream = createServer({ cert: upstreamLeaf.certPem, key: upstreamLeaf.keyPem }, handler); + const upstream = createServer({ cert: upstreamLeaf.certPem, key: upstreamLeaf.keyPem, maxHeaderSize: PICKER_MAX_HEADER_BYTES }, handler); + const sockets = new Set(); + for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve)); const address = upstream.address(); if (!address || typeof address === "string") throw new Error("upstream port missing"); @@ -36,24 +45,48 @@ async function fixture( }); const close = async () => { await relay.close(); + for (const socket of sockets) socket.destroy(); await new Promise((resolve, reject) => upstream.close(error => error ? reject(error) : resolve())); }; const get = (path: string, method = "GET") => clientRequest(relay, ca.certPem, path, method); return { upstream, relay, ca: ca.certPem, get, logs, close }; } -function clientRequest(relay: PickerListenerHandle, ca: string, path: string, method = "GET"): Promise { +function clientRequest(relay: PickerListenerHandle, ca: string, path: string, method = "GET", + options: { headers?: Record; body?: Buffer; onChunk?: (chunk: Buffer) => void } = {}, +): Promise { return new Promise((resolve, reject) => { const req = request({ host: "127.0.0.1", port: relay.port, servername: "claude.ai", ca, - rejectUnauthorized: true, path, method, headers: { Host: "claude.ai" }, agent: false }, res => { + rejectUnauthorized: true, path, method, headers: { Host: "claude.ai", ...options.headers }, + agent: false, maxHeaderSize: PICKER_MAX_HEADER_BYTES }, res => { const chunks: Buffer[] = []; - res.on("data", chunk => chunks.push(chunk)); + res.on("data", chunk => { chunks.push(chunk); options.onChunk?.(chunk); }); res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) })); res.on("error", reject); }); + const timeout = setTimeout(() => req.destroy(new Error("picker request timed out")), 2000); + req.once("close", () => clearTimeout(timeout)); req.on("error", reject); - req.end(); + req.end(options.body); + }); +} + +// Raw TLS avoids the test client's own HTTP parser imposing a header limit. +function rawExchange(f: Awaited>, wire: Buffer, + onData?: (socket: TLSSocket, received: Buffer) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const socket = tlsConnect({ host: "127.0.0.1", port: f.relay.port, servername: "claude.ai", ca: f.ca, + rejectUnauthorized: true }, () => socket.write(wire)); + const chunks: Buffer[] = []; + const timeout = setTimeout(() => socket.destroy(new Error("raw picker exchange timed out")), 2000); + socket.on("data", chunk => { + chunks.push(chunk); + if (onData?.(socket, Buffer.concat(chunks))) socket.destroy(); + }); + socket.once("error", reject); + socket.once("close", () => { clearTimeout(timeout); resolve(Buffer.concat(chunks)); }); }); } @@ -74,36 +107,54 @@ test("opaque gzip body and duplicate Set-Cookie headers survive", async () => { } finally { await f.close(); } }); -test("SSE first chunk reaches the client before upstream end", async () => { - let finish!: () => void; - const f = await fixture((_req, res) => { - res.writeHead(200, { "Content-Type": "text/event-stream" }); - res.write("data: first\n\n"); - finish = () => res.end("data: second\n\n"); - }); - try { - const first = new Promise((resolve, reject) => { - const req = request({ host: "127.0.0.1", port: f.relay.port, servername: "claude.ai", ca: f.ca, - path: "/events", headers: { Host: "claude.ai" }, agent: false }, res => { - res.once("data", chunk => resolve(chunk.toString())); - }); - req.on("error", reject); - req.end(); +for (const contentType of ["text/event-stream", "application/octet-stream"]) { + test(`large-header ${contentType} streams first bytes before upstream end and all bytes exactly`, async () => { + const first = contentType === "text/event-stream" ? Buffer.from("data: first\n\n") : binary; + const last = contentType === "text/event-stream" ? Buffer.from("data: second\n\n") : Buffer.from([254, 0, 129]); + let finish!: () => void; + let ended = false; + const arrived = Promise.withResolvers(); + const early: Buffer[] = []; + const f = await fixture((req, res) => { + expect(req.headers.cookie).toBe(largeCookie); + res.writeHead(200, { "Content-Type": contentType, "Set-Cookie": largeCookie }); + res.write(first); + finish = () => { ended = true; res.end(last); }; }); - expect(await first).toBe("data: first\n\n"); - finish(); - } finally { await f.close(); } -}); + try { + const complete = clientRequest(f.relay, f.ca, "/events", "GET", { + headers: { Cookie: largeCookie }, onChunk: chunk => { + early.push(chunk); + const bytes = Buffer.concat(early); + if (bytes.length >= first.length) arrived.resolve(bytes); + }, + }); + // Failure or premature completion settles the first-byte wait without a sleep. + void complete.then(() => arrived.reject(new Error("stream ended before first bytes")), arrived.reject); + expect((await arrived.promise).equals(first)).toBe(true); + expect(ended).toBe(false); + finish(); + const received = await complete; + expect(received.status).toBe(200); + expect(received.headers["set-cookie"]).toEqual([largeCookie]); + expect(received.body.equals(Buffer.concat([first, last]))).toBe(true); + expect(f.logs).toEqual(["picker GET other 200"]); + } finally { await f.close(); } + }); +} -test("bootstrap JSON is injected and emitted with identity headers", async () => { +test("bootstrap with large request and response cookies is injected with identity headers", async () => { const gzipped = gzipSync(bootstrap); - const f = await fixture((_req, res) => { + const f = await fixture((req, res) => { + expect(req.headers.cookie).toBe(largeCookie); res.writeHead(200, { "Content-Type": "application/json", "Content-Encoding": "gzip", - "Content-Length": String(gzipped.length), ETag: "old" }); + "Content-Length": String(gzipped.length), ETag: "old", "Set-Cookie": [largeCookie, "second=synthetic"] }); res.end(gzipped); }); try { - const received = await f.get("/edge-api/bootstrap/org/app_start?cache_bust=1"); + const received = await clientRequest(f.relay, f.ca, "/edge-api/bootstrap/org/app_start?cache_bust=1", "GET", + { headers: { Cookie: largeCookie } }); + expect(received.headers["set-cookie"]).toEqual([largeCookie, "second=synthetic"]); expect(received.status).toBe(200); expect(received.headers["content-encoding"]).toBeUndefined(); expect(received.headers.etag).toBeUndefined(); @@ -154,59 +205,124 @@ test("untrusted upstream certificate yields an empty 502", async () => { } finally { await f.close(); } }); -test("WebSocket upgrade carries bytes in both directions", async () => { +test("large-cookie upgrade preserves coalesced binary head and bidirectional echo", async () => { const f = await fixture((_req, res) => res.end("ordinary")); - f.upstream.on("upgrade", (_req, socket) => { - socket.write("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); - socket.on("data", data => socket.write(data)); + const prefix = "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"; + const echo = Buffer.from([0, 253, 128, 10, 255]); + const observed = Promise.withResolvers(); + let cookie: string | undefined; + f.upstream.on("upgrade", (req, socket, head) => { + cookie = req.headers.cookie; + const chunks = [head]; + socket.write(prefix); + if (head.length) socket.write(head); + socket.on("data", chunk => { + chunks.push(chunk); + socket.write(chunk); + if (Buffer.concat(chunks).length === binary.length + echo.length) observed.resolve(Buffer.concat(chunks)); + }); }); try { - const result = await new Promise((resolve, reject) => { - const socket = tlsConnect({ host: "127.0.0.1", port: f.relay.port, servername: "claude.ai", ca: f.ca, - rejectUnauthorized: true }, () => { - socket.write("GET /api/ws/test HTTP/1.1\r\nHost: claude.ai\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); - }); - let text = ""; - socket.on("data", chunk => { - text += chunk.toString(); - if (text.includes("\r\n\r\n") && !text.includes("echo-me")) socket.write("echo-me"); - if (text.includes("echo-me")) { socket.destroy(); resolve(text); } + let sent = false; + const received = await rawExchange(f, Buffer.concat([Buffer.from( + `GET /api/ws/synthetic HTTP/1.1\r\nHost: claude.ai\r\nCookie: ${largeCookie}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`), binary]), + (socket, bytes) => { + if (!sent && bytes.length >= Buffer.byteLength(prefix)) { sent = true; socket.write(echo); } + return bytes.length >= Buffer.byteLength(prefix) + binary.length + echo.length; }); - socket.on("error", reject); - }); - expect(result).toContain("101 Switching Protocols"); - expect(result).toContain("echo-me"); + expect(received.equals(Buffer.concat([Buffer.from(prefix), binary, echo]))).toBe(true); + expect((await observed.promise).equals(Buffer.concat([binary, echo]))).toBe(true); + expect(cookie).toBe(largeCookie); + expect(f.logs).toEqual(["picker GET other 101"]); } finally { await f.close(); } }); -test("ordinary request method, body and headers relay upstream", async () => { - let observed: { method?: string; header?: string; body: string } | undefined; +test("untrusted upgrade TLS fails with fixed logs and no synthetic request data", async () => { + let upgraded = false; + const f = await fixture((_req, res) => res.end("must not arrive"), { untrusted: true }); + f.upstream.on("upgrade", () => { upgraded = true; }); + try { + const received = await rawExchange(f, Buffer.concat([Buffer.from( + `GET /synthetic-private-path?query=synthetic-private-query HTTP/1.1\r\nHost: claude.ai\r\nCookie: ${largeCookie}\r\nX-Synthetic: private-header\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`), binary])); + expect(received.toString()).toBe("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + expect(upgraded).toBe(false); + expect(f.logs).toEqual(["picker GET other 502"]); + } finally { await f.close(); } +}); + +test("ordinary large request cookie and binary POST body reach upstream unchanged", async () => { + let observed: { method?: string; header?: string; cookie?: string; body: Buffer } | undefined; const f = await fixture((req, res) => { const chunks: Buffer[] = []; req.on("data", chunk => chunks.push(chunk)); req.on("end", () => { observed = { method: req.method, header: req.headers["x-test"] as string, - body: Buffer.concat(chunks).toString() }; - res.writeHead(201, { "Content-Type": "text/plain" }); - res.end("ok"); + cookie: req.headers.cookie, body: Buffer.concat(chunks) }; + res.writeHead(201, { "Content-Type": "application/octet-stream" }); + res.end(binary); }); }); try { - const received = await new Promise((resolve, reject) => { - const req = request({ host: "127.0.0.1", port: f.relay.port, servername: "claude.ai", ca: f.ca, - rejectUnauthorized: true, path: "/submit", method: "POST", agent: false, - headers: { Host: "claude.ai", "X-Test": "retained", "Content-Length": "7" } }, res => { - const chunks: Buffer[] = []; - res.on("data", chunk => chunks.push(chunk)); - res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, - rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) })); - }); - req.on("error", reject); - req.end("payload"); + const received = await clientRequest(f.relay, f.ca, "/submit", "POST", { + headers: { "X-Test": "retained", Cookie: largeCookie, "Content-Length": String(binary.length) }, body: binary, }); expect(received.status).toBe(201); - expect(received.body.toString()).toBe("ok"); - expect(observed).toEqual({ method: "POST", header: "retained", body: "payload" }); + expect(received.body.equals(binary)).toBe(true); + expect(observed).toEqual({ method: "POST", header: "retained", cookie: largeCookie, body: binary }); expect(f.logs).toEqual(["picker POST other 201"]); } finally { await f.close(); } }); + +test("ordinary large response cookies preserve duplicate order and binary body", async () => { + const cookies = [largeCookie + "; HttpOnly; Secure", "second=synthetic; Secure"]; + const f = await fixture((_req, res) => { + res.writeHead(200, ["Set-Cookie", cookies[0]!, "Set-Cookie", cookies[1]!, + "Content-Type", "application/octet-stream", "Content-Length", String(binary.length)]); + res.end(binary); + }); + try { + const received = await f.get("/api/organizations/synthetic/chat_conversations"); + expect(received.status).toBe(200); + expect(received.headers["set-cookie"]).toEqual(cookies); + const rawCookies = received.rawHeaders.flatMap((name, i, raw) => + i % 2 === 0 && name.toLowerCase() === "set-cookie" ? [raw[i + 1]] : []); + expect(rawCookies).toEqual(cookies); + expect(received.body.equals(binary)).toBe(true); + expect(f.logs).toEqual(["picker GET other 200"]); + } finally { await f.close(); } +}); + +test("response headers over the bounded picker limit fail closed with fixed logs", async () => { + expect(PICKER_MAX_HEADER_BYTES).toBe(HEADER_LIMIT); + const body = Buffer.from("synthetic-private-request-body!"); + const f = await fixture((_req, res) => { + res.writeHead(200, { "Set-Cookie": "synthetic-private-header=" + "x".repeat(HEADER_LIMIT) }); + res.end("synthetic-private-response-body"); + }); + try { + const received = await clientRequest(f.relay, f.ca, "/synthetic-private-path?query=synthetic-private-query", "POST", { + headers: { "X-Synthetic": "synthetic-private-request-header", "Content-Length": String(body.length) }, + body, + }); + expect(received.status).toBe(502); + expect(received.body.length).toBe(0); + expect(f.logs).toEqual(["picker POST other upstream:headers-too-large", "picker POST other 502"]); + } finally { await f.close(); } +}); + +for (const [kind, headers] of [ + ["single", `Cookie: synthetic-private-header=${"x".repeat(HEADER_LIMIT + 1024)}\r\n`], + ["aggregate", Array.from({ length: 5 }, (_, i) => `X-Synthetic-${i}: ${"x".repeat(14 * 1024)}\r\n`).join("")], +]) { + test(`${kind} inbound headers above 64 KiB are rejected before upstream`, async () => { + let requests = 0; + const f = await fixture((_req, res) => { requests++; res.end("must not arrive"); }); + try { + const received = await rawExchange(f, Buffer.from( + `GET /synthetic-private-path?query=synthetic-private-query HTTP/1.1\r\nHost: claude.ai\r\n${headers}Connection: close\r\n\r\n`)); + expect(requests).toBe(0); + expect(received.toString()).toBe("HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n"); + expect(f.logs).toEqual([]); + } finally { await f.close(); } + }); +}