diff --git a/src/claude/intercept/connect-proxy.ts b/src/claude/intercept/connect-proxy.ts index c0b804a6759..f51f06e7d18 100644 --- a/src/claude/intercept/connect-proxy.ts +++ b/src/claude/intercept/connect-proxy.ts @@ -32,6 +32,8 @@ export interface ConnectProxyOptions { authToken?: string | (() => string | null); /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */ interceptHosts?: readonly string[]; + /** Optional exact CONNECT authorities (host:port); empty denies all, absent keeps blind relay. */ + allowedTargets?: readonly string[]; /** Per-connection override, consulted before interceptHosts; null keeps the default. */ selectTunnel?: (host: string, port: number, request: ConnectRequestInfo) => TunnelDecision | null | Promise; /** Test seam: dial the real destination for a blind tunnel. */ @@ -67,7 +69,8 @@ function connectRequestInfo(head: string): ConnectRequestInfo { } type ResolvedConnectProxyOptions = Required> - & Pick; + & Pick + & { allowedTargets?: ReadonlySet }; export interface ConnectProxyHandle { port: number; @@ -186,6 +189,10 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions): respond(socket, 403, "Forbidden"); return; } + if (options.allowedTargets && !options.allowedTargets.has(`${target.host}:${target.port}`)) { + respond(socket, 403, "Forbidden"); + return; + } const dialFor = (selected: TunnelDecision | null): void => { if (socket.destroyed) return; const choice = selected ?? (target.port === 443 && options.interceptHosts.includes(target.host) @@ -258,10 +265,19 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions): /** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */ export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise { + // Snapshot caller-owned policy before listening. Reuse the request parser's + // host normalization; malformed policy must not silently disable restrictions. + const allowedTargets = options.allowedTargets === undefined ? undefined : new Set(options.allowedTargets.map(authority => { + const target = typeof authority === "string" && !/[\s\r\n]/.test(authority) + ? parseConnectRequestLine(`CONNECT ${authority} HTTP/1.1`) : null; + if (!target || /[*/\\?#@%]/.test(target.host)) throw new Error("Invalid CONNECT allowed target"); + return `${target.host}:${target.port}`; + })); const resolved: ResolvedConnectProxyOptions = { interceptPort: options.interceptPort, authToken: options.authToken, interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS, + allowedTargets, selectTunnel: options.selectTunnel, dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })), }; diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts index 79f8063d385..109ba91bd6b 100644 --- a/src/claude/intercept/local-ca.ts +++ b/src/claude/intercept/local-ca.ts @@ -193,12 +193,18 @@ export interface LocalInterceptCa extends PemKeyPair { export interface AuthorityOptions { commonName: string; + /** Optional whole-day lifetime for short-lived authorities; defaults to the existing 3650 days. */ + validityDays?: number; permittedDnsNames?: readonly string[]; /** With permittedDnsNames: also exclude every IP address (default true). */ excludeAllIpAddresses?: boolean; } export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa { + const validityDays = options.validityDays ?? CA_VALIDITY_DAYS; + if (!Number.isInteger(validityDays) || validityDays < 1 || validityDays > CA_VALIDITY_DAYS) { + throw new Error("CA validityDays must be an integer between 1 and 3650"); + } const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); const name = distinguishedName(options.commonName); const der = issueCertificate({ @@ -206,7 +212,7 @@ export function createCertificateAuthority(options: AuthorityOptions): LocalInte issuer: name, subjectKey: publicKey, signingKey: privateKey, - validityDays: CA_VALIDITY_DAYS, + validityDays, extensions: [ extension(OID.basicConstraints, true, sequence(boolean(true), tlv(0x02, Uint8Array.of(0)))), // keyCertSign | cRLSign diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 075731e0fda..6f038c5dab5 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -137,6 +137,12 @@ event loop. Injected probes may return a state or a promise, so isolated callers ### Picker mode: the Desktop egress proxy +The shared CONNECT primitive accepts optional `allowedTargets` authorities. It snapshots and +normalizes that list at startup; an empty list denies all, and other host/port pairs receive 403 +before tunnel selection or dialing. Authentication and loopback refusal remain in force. +Existing Claude consumers omit this option and retain blind forwarding; it enables no new integration or certificate trust. +The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. + When the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also wires Claude Desktop picker mode: a second loopback CONNECT proxy on the dedicated picker proxy port (`getClaudeInterceptState()?.pickerProxyPort`), used as Desktop's pinned egress proxy. Desktop diff --git a/structure/runtime.md b/structure/runtime.md index 84c3c0d7095..43914d50321 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -248,7 +248,7 @@ still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.c listener, relays every other CONNECT target blind, and refuses unauthenticated clients, plain proxied HTTP, and loopback targets. The per-install proxy token is stored owner-only under `/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it. The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream. The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). Bind failure warns, and stop joins both sockets. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required. -This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode adds a second Desktop CONNECT proxy on the next port; see [Claude Desktop](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). +This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). `src/claude/intercept/client-class.ts` classifies each request by its Claude Code `User-Agent` entrypoint: `claude-desktop`, `claude-desktop-3p`, and `local-agent` are Desktop; other well-formed `claude-cli/ (external, )` values are CLI; absent or malformed values are unknown. Only a client with its own first-party intent enabled (Desktop mode or `claudeCode.cliFirstParty`) enters the router for Messages paths; other paths use the configured upstream relay. diff --git a/tests/claude-integration/claude-intercept-local-ca.test.ts b/tests/claude-integration/claude-intercept-local-ca.test.ts index 053e6c916c1..00b60199018 100644 --- a/tests/claude-integration/claude-intercept-local-ca.test.ts +++ b/tests/claude-integration/claude-intercept-local-ca.test.ts @@ -9,6 +9,7 @@ import { claudeInterceptCaCertPath, claudeInterceptStateDir, createLocalInterceptCa, + createCertificateAuthority, ensureLocalInterceptCa, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf, @@ -30,6 +31,18 @@ test("CA certificate is a self-signed X.509 v3 authority", () => { expect(new Date(cert.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 24 * 3600 * 1000); }); +test("temporary authorities can shorten validity without changing the default", () => { + const short = createCertificateAuthority({ commonName: "temporary fixture", validityDays: 1, permittedDnsNames: ["example.test"] }); + const cert = new X509Certificate(short.certPem); + expect(Date.parse(cert.validTo) - Date.parse(cert.validFrom)).toBe(86_400_000); + expect(cert.verify(short.publicKey)).toBe(true); + const normal = new X509Certificate(createLocalInterceptCa().certPem); + expect(Date.parse(normal.validTo) - Date.parse(normal.validFrom)).toBe(3650 * 86_400_000); + for (const validityDays of [0, -1, 0.5, NaN, Infinity, 3651]) { + expect(() => createCertificateAuthority({ commonName: "invalid fixture", validityDays })).toThrow("validityDays"); + } +}); + test("leaf is issued by the CA and names every requested host in SAN", () => { const ca = createLocalInterceptCa(); const leaf = issueLocalInterceptLeaf(ca, ["api.anthropic.com", "example.test"]); diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts index 7e70286c4e2..0142e0b0646 100644 --- a/tests/claude-integration/claude-intercept-proxy.test.ts +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -239,6 +239,57 @@ test("invalid request and loopback are refused before consulting tunnel choice", expect(consulted).toBe(0); }); +test("restricted CONNECT admits only exact normalized authorities before tunnel selection", async () => { + const echo = await startEchoUpstream(); + cleanups.push(echo.close); + const selected: string[] = []; + let blindDials = 0; + const allowedTargets = ["CHATGPT.COM.:443"]; + const proxy = await startConnectProxy(0, { + interceptPort: echo.port, + interceptHosts: ["chatgpt.com"], + allowedTargets, + selectTunnel: (host, port) => { selected.push(`${host}:${port}`); return null; }, + dialUpstream: () => { blindDials++; return connect({ host: "127.0.0.1", port: echo.port }); }, + }); + cleanups.push(proxy.close); + // A caller mutating its original array cannot broaden a running listener. + allowedTargets.push("other.example:443"); + expect(await tunnelPayload(proxy.port, "ChatGPT.Com.")).toContain("echo:hello"); + for (const authority of ["other.example:443", "chatgpt.com:8443", "child.chatgpt.com:443", "chatgpt.com.evil.example:443"]) { + expect(await rawRequest(proxy.port, `CONNECT ${authority} HTTP/1.1\r\nUser-Agent: Mozilla/test\r\n\r\npipelined`)).toStartWith("HTTP/1.1 403"); + } + expect(selected).toEqual(["chatgpt.com:443"]); + expect(blindDials).toBe(0); +}); + +test("empty CONNECT allowlist denies all and cannot enter a failing selector", async () => { + let called = false; + const proxy = await startConnectProxy(0, { + interceptPort: 1, + allowedTargets: [], + selectTunnel: () => { called = true; throw new Error("must not run"); }, + }); + cleanups.push(proxy.close); + expect(await rawRequest(proxy.port, "CONNECT api.anthropic.com:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(called).toBe(false); +}); + +test("destination restriction does not replace authentication or loopback refusal", async () => { + const proxy = await startConnectProxy(0, { + interceptPort: 1, authToken: AUTH_TOKEN, allowedTargets: ["chatgpt.com:443", "127.0.0.1:443"], + }); + cleanups.push(proxy.close); + expect(await rawRequest(proxy.port, "CONNECT chatgpt.com:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 407"); + expect(await rawRequest(proxy.port, `CONNECT 127.0.0.1:443 HTTP/1.1\r\n${AUTH_HEADER}\r\n`)).toStartWith("HTTP/1.1 403"); +}); + +test("malformed destination restrictions fail before a listener starts", () => { + for (const authority of ["", "*.example.com:443", "example.com", "example.com:0", "example.com:65536", "example.com:443\r\nX: value", "https://example.com:443"]) { + expect(() => startConnectProxy(0, { interceptPort: 1, allowedTargets: [authority] })).toThrow("Invalid CONNECT allowed target"); + } +}); + test("plain proxied HTTP, loopback targets and oversized heads are refused", async () => { const { proxy } = await startPair(); expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")).toStartWith("HTTP/1.1 405");