diff --git a/proxy/forward-proxy.mjs b/proxy/forward-proxy.mjs index f5f2089b..d4b5f436 100644 --- a/proxy/forward-proxy.mjs +++ b/proxy/forward-proxy.mjs @@ -472,6 +472,20 @@ function handleDownloadsRequest(clientReq, clientRes) { upReq.end(); } +// Absolute-form entry into the download rewrite. The CONNECT-MITM path feeds +// decrypted downloads.claude.ai requests into handleDownloadsRequest; a client +// that skips CONNECT (axios's plain-proxy mode, RFC 7230 §5.3.2) delivers the +// same request as an absolute-form GET on the proxy port. Route it through the +// same rewrite so both arrival styles get the acceleration; when the rewrite +// is inactive the caller falls through to the generic relay (origin — slower +// but correct). Returns true when the request was taken over. +export function handleDownloadsAbsolute(clientReq, clientRes, url) { + if (url.hostname !== DOWNLOADS_HOST || !downloadRewriteActive()) return false; + clientReq.url = url.pathname + url.search; + handleDownloadsRequest(clientReq, clientRes); + return true; +} + // A dedicated http.Server whose sole job is to serve the decrypted // downloads.claude.ai stream via the storage rewrite. Built lazily so the // upstream MITM path is untouched when download-rewrite is off. diff --git a/proxy/server.mjs b/proxy/server.mjs index 0162694f..1c5f1bc2 100644 --- a/proxy/server.mjs +++ b/proxy/server.mjs @@ -1,12 +1,12 @@ import http from "node:http"; import { pathToFileURL, URL } from "node:url"; import config from "./config.mjs"; -import { forwardRequest } from "./upstream.mjs"; +import { forwardRequest, parseAbsoluteForm } from "./upstream.mjs"; import { streamResponse, createTelemetryRecord } from "./stream.mjs"; import { loadExtensions, snapshotRegistry, runOnRequest, runOnResponseStart, runOnResponse, getFailedExtensions } from "./pipeline.mjs"; import { startWatcher } from "./watcher.mjs"; import { startOAuthRefresher, stopOAuthRefresher } from "./oauth/refresher.mjs"; -import { attachForwardProxy } from "./forward-proxy.mjs"; +import { attachForwardProxy, handleDownloadsAbsolute } from "./forward-proxy.mjs"; // Debug logging — writes to ~/.claude/cache-fix-debug.log (override path with // CACHE_FIX_DEBUG_LOG). Self-gated on CACHE_FIX_DEBUG=1; a no-op otherwise. @@ -418,6 +418,29 @@ export function createProxyServer() { return originalEnd.apply(res, [chunk, ...args]); }; + // RFC 7230 §5.3.2 absolute-form request-target. A proxy-configured + // client does not always tunnel: axios's plain-proxy mode (the CLI's + // auto-updater / telemetry paths) sends `GET https://host/path` on the + // proxy connection instead of CONNECT. Only meaningful in forward mode + // — reverse mode keeps its 404 contract for such targets (see below). + if (_forwardActive > 0) { + const abs = parseAbsoluteForm(req.url); + if (abs) { + // downloads.claude.ai with the rewrite active: same acceleration + // as the CONNECT-MITM arrival style. + if (handleDownloadsAbsolute(req, res, abs)) return; + // Targets on the upstream reduce to origin-form so the normal + // routing below (incl. the /v1/messages transform) applies. + // Compare origins, not hostnames: scheme and port are part of + // the authority (two servers on one host differ only by port). + // Foreign targets fall through to handlePassthrough, where + // buildUpstreamUrl honors the absolute-form authority. + let upOrigin = ""; + try { upOrigin = new URL(config.upstream).origin; } catch {} + if (abs.origin === upOrigin) req.url = abs.pathname + abs.search; + } + } + if (req.method === "GET" && req.url === "/health") return handleHealth(req, res); if (req.method === "POST" && req.url?.startsWith("/v1/messages")) return await handleMessages(req, res); if (req.url?.startsWith("/api/claude_cli/bootstrap")) return await handleBootstrap(req, res); diff --git a/proxy/upstream.mjs b/proxy/upstream.mjs index 42813f1e..6b291bab 100644 --- a/proxy/upstream.mjs +++ b/proxy/upstream.mjs @@ -186,6 +186,17 @@ export function getAgent(isHTTPS, hostname) { return agent; } +// RFC 7230 §5.3.2 absolute-form request-target. A client configured with +// HTTP(S)_PROXY does not always tunnel: axios's built-in proxy mode (which the +// Claude Code CLI's auto-updater and telemetry paths use) sends +// `GET https://host/path HTTP/1.1` on the plain proxy connection instead of +// issuing CONNECT. The authority inside that URI is the routing instruction. +// Returns a URL for absolute-form targets, null for origin-form ones. +export function parseAbsoluteForm(target) { + if (!/^https?:\/\//i.test(target || "")) return null; + try { return new URL(target); } catch { return null; } +} + // Build the upstream URL by concatenating the configured base (with any path // component preserved) with the client request URL. The historical // `new URL(clientReq.url, base)` approach is RFC 3986 relative-resolution, @@ -195,6 +206,13 @@ export function getAgent(isHTTPS, hostname) { // — the request would land at `https://corp-proxy.example.net/v1/messages` // with `/anthropic-mirror` silently dropped. See PR #188 / @nisqatsi. export function buildUpstreamUrl(base, clientUrl) { + // Absolute-form carries its own authority — honor it. Concatenating it onto + // the base misroutes the request to the upstream host + // (`api.anthropic.com/https://downloads.claude.ai/...` → the upstream CDN + // answers 404), which surfaces to the user as a permanent + // "✘ Auto-update failed" banner plus failed telemetry exports. + const abs = parseAbsoluteForm(clientUrl); + if (abs) return abs; const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base; const relative = clientUrl.startsWith("/") ? clientUrl : "/" + clientUrl; return new URL(trimmedBase + relative); diff --git a/test/proxy-forward-absolute-form.test.mjs b/test/proxy-forward-absolute-form.test.mjs new file mode 100644 index 00000000..da5242fc --- /dev/null +++ b/test/proxy-forward-absolute-form.test.mjs @@ -0,0 +1,195 @@ +// Tests for RFC 7230 §5.3.2 absolute-form request-targets in forward-proxy mode. +// +// A client configured with HTTP(S)_PROXY does not always tunnel: axios's +// built-in proxy support (which the Claude Code CLI's auto-updater and +// telemetry paths use) sends `GET https://host/path HTTP/1.1` on the plain +// proxy connection instead of issuing CONNECT. A conforming HTTP proxy must +// honor the authority in that request-target. The proxy instead treated the +// absolute URI as an origin-form path and concatenated it onto the configured +// upstream (`https://api.anthropic.com/https://downloads.claude.ai/...`), +// misrouting every such request to the upstream host — Cloudflare answers 404 +// and the CLI renders a permanent "✘ Auto-update failed" banner (its 1P event +// export and Datadog flush 404 the same way). +// +// Contract under test: +// - forward mode, absolute-form to a FOREIGN host -> relayed to that host +// - forward mode, absolute-form to the UPSTREAM -> behaves as origin-form +// - reverse mode, absolute-form -> 404 (contract unchanged) + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startProxy } from "../proxy/server.mjs"; + +const ENV_KEYS = [ + "CACHE_FIX_FORWARD_PROXY", "CACHE_FIX_CA_DIR", "CACHE_FIX_PROXY_UPSTREAM", + "CACHE_FIX_HTTPS_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", + "NO_PROXY", "no_proxy", +]; + +function saveEnv() { + const saved = {}; + for (const k of ENV_KEYS) saved[k] = process.env[k]; + return saved; +} +function restoreEnv(saved) { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +} + +function listen(server) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server.address().port))); +} + +// Send a raw absolute-form request THROUGH the proxy port: the request line's +// target is the full URI, exactly what axios emits to a plain HTTP proxy. +function absoluteFormRequest(proxyPort, method, absoluteUrl, body) { + return new Promise((resolve, reject) => { + const req = http.request( + { hostname: "127.0.0.1", port: proxyPort, method, path: absoluteUrl }, + (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); + } + ); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +test("forward mode: absolute-form to a foreign host is relayed to that host, not the upstream", async () => { + const saved = saveEnv(); + const caDir = mkdtempSync(join(tmpdir(), "absform-ca-")); + + const upstreamHits = []; + const upstream = http.createServer((req, res) => { + upstreamHits.push(req.url); + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + const upstreamPort = await listen(upstream); + + const foreignHits = []; + const foreign = http.createServer((req, res) => { + foreignHits.push({ url: req.url, host: req.headers.host }); + res.writeHead(200, { "content-type": "text/plain" }); + res.end("2.1.999"); + }); + const foreignPort = await listen(foreign); + + let handle; + try { + process.env.CACHE_FIX_FORWARD_PROXY = "on"; + process.env.CACHE_FIX_CA_DIR = caDir; + process.env.CACHE_FIX_PROXY_UPSTREAM = `http://127.0.0.1:${upstreamPort}`; + delete process.env.CACHE_FIX_HTTPS_PROXY; + delete process.env.HTTPS_PROXY; delete process.env.HTTP_PROXY; + delete process.env.https_proxy; delete process.env.http_proxy; + delete process.env.NO_PROXY; delete process.env.no_proxy; + + handle = await startProxy({ port: 0, watch: false }); + + const r = await absoluteFormRequest( + handle.port, "GET", `http://127.0.0.1:${foreignPort}/claude-code-releases/latest`); + + assert.equal(r.status, 200, "absolute-form request must reach its own target host"); + assert.equal(r.body, "2.1.999", "response body must stream back from the target"); + assert.equal(foreignHits.length, 1, "target host must be hit exactly once"); + assert.equal(foreignHits[0].url, "/claude-code-releases/latest", + "target must receive the origin-form path, not the absolute URI"); + assert.deepEqual(upstreamHits, [], + "the upstream must NOT see a foreign-host absolute-form request"); + } finally { + restoreEnv(saved); + if (handle) await handle.close(); + upstream.close(); + foreign.close(); + try { rmSync(caDir, { recursive: true, force: true }); } catch {} + } +}); + +test("forward mode: absolute-form to the upstream host behaves as origin-form (path + body intact)", async () => { + const saved = saveEnv(); + const caDir = mkdtempSync(join(tmpdir(), "absform-up-ca-")); + + const upstreamHits = []; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + upstreamHits.push({ url: req.url, body: Buffer.concat(chunks).toString() }); + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + }); + const upstreamPort = await listen(upstream); + + let handle; + try { + process.env.CACHE_FIX_FORWARD_PROXY = "on"; + process.env.CACHE_FIX_CA_DIR = caDir; + process.env.CACHE_FIX_PROXY_UPSTREAM = `http://127.0.0.1:${upstreamPort}`; + delete process.env.CACHE_FIX_HTTPS_PROXY; + delete process.env.HTTPS_PROXY; delete process.env.HTTP_PROXY; + delete process.env.https_proxy; delete process.env.http_proxy; + delete process.env.NO_PROXY; delete process.env.no_proxy; + + handle = await startProxy({ port: 0, watch: false }); + + const r = await absoluteFormRequest( + handle.port, "POST", + `http://127.0.0.1:${upstreamPort}/api/event_logging/v2/batch`, + '{"events":[]}'); + + assert.equal(r.status, 200); + assert.equal(upstreamHits.length, 1, "upstream must be hit exactly once"); + assert.equal(upstreamHits[0].url, "/api/event_logging/v2/batch", + "upstream must receive the origin-form path, not a concatenated absolute URI"); + assert.equal(upstreamHits[0].body, '{"events":[]}', "request body must pass through"); + } finally { + restoreEnv(saved); + if (handle) await handle.close(); + upstream.close(); + try { rmSync(caDir, { recursive: true, force: true }); } catch {} + } +}); + +test("reverse mode: absolute-form keeps the 404 contract (no relay)", async () => { + const saved = saveEnv(); + + const foreignHits = []; + const foreign = http.createServer((req, res) => { + foreignHits.push(req.url); + res.writeHead(200); + res.end("nope"); + }); + const foreignPort = await listen(foreign); + + let handle; + try { + delete process.env.CACHE_FIX_FORWARD_PROXY; + delete process.env.CACHE_FIX_HTTPS_PROXY; + delete process.env.HTTPS_PROXY; delete process.env.HTTP_PROXY; + delete process.env.https_proxy; delete process.env.http_proxy; + + handle = await startProxy({ port: 0, watch: false }); + + const r = await absoluteFormRequest( + handle.port, "GET", `http://127.0.0.1:${foreignPort}/anything`); + + assert.equal(r.status, 404, "reverse mode must not act as a forward proxy"); + assert.deepEqual(foreignHits, [], "reverse mode must not relay absolute-form requests"); + } finally { + restoreEnv(saved); + if (handle) await handle.close(); + foreign.close(); + } +});