Skip to content

Commit 8b25dc9

Browse files
authored
fix(forward-proxy): honor RFC 7230 absolute-form request-targets (cnighswonger#261)
axios's built-in proxy mode — which the CC CLI uses for its auto-updater, 1P event export, and Datadog flush — skips CONNECT and sends absolute-form targets on the plain proxy connection. buildUpstreamUrl() treated the absolute URI as an origin-form path and concatenated it onto the upstream, so every session start 404'd its version check and pinned a permanent 'Auto-update failed' banner. parseAbsoluteForm() in upstream.mjs returns the authority when present. Forward mode normalizes before route dispatch: upstream-origin targets reduce to origin-form (so /v1/messages still gets the cache transform), downloads.claude.ai routes through the download rewrite, foreign hosts relay to their real destination. Reverse mode keeps its 404 contract, test-pinned. Security posture is unchanged in kind: forward mode already blind-tunnels arbitrary CONNECT targets, and the proxy binds 127.0.0.1 by default. This is parity for clients that skip CONNECT. Verified locally merged onto main: full suite 1440/0. The cnighswonger#188 base-path contract is intact. Closes cnighswonger#261
1 parent 0770147 commit 8b25dc9

4 files changed

Lines changed: 252 additions & 2 deletions

File tree

proxy/forward-proxy.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,20 @@ function handleDownloadsRequest(clientReq, clientRes) {
472472
upReq.end();
473473
}
474474

475+
// Absolute-form entry into the download rewrite. The CONNECT-MITM path feeds
476+
// decrypted downloads.claude.ai requests into handleDownloadsRequest; a client
477+
// that skips CONNECT (axios's plain-proxy mode, RFC 7230 §5.3.2) delivers the
478+
// same request as an absolute-form GET on the proxy port. Route it through the
479+
// same rewrite so both arrival styles get the acceleration; when the rewrite
480+
// is inactive the caller falls through to the generic relay (origin — slower
481+
// but correct). Returns true when the request was taken over.
482+
export function handleDownloadsAbsolute(clientReq, clientRes, url) {
483+
if (url.hostname !== DOWNLOADS_HOST || !downloadRewriteActive()) return false;
484+
clientReq.url = url.pathname + url.search;
485+
handleDownloadsRequest(clientReq, clientRes);
486+
return true;
487+
}
488+
475489
// A dedicated http.Server whose sole job is to serve the decrypted
476490
// downloads.claude.ai stream via the storage rewrite. Built lazily so the
477491
// upstream MITM path is untouched when download-rewrite is off.

proxy/server.mjs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import http from "node:http";
22
import { pathToFileURL, URL } from "node:url";
33
import config from "./config.mjs";
4-
import { forwardRequest } from "./upstream.mjs";
4+
import { forwardRequest, parseAbsoluteForm } from "./upstream.mjs";
55
import { streamResponse, createTelemetryRecord } from "./stream.mjs";
66
import { loadExtensions, snapshotRegistry, runOnRequest, runOnResponseStart, runOnResponse, getFailedExtensions } from "./pipeline.mjs";
77
import { startWatcher } from "./watcher.mjs";
88
import { startOAuthRefresher, stopOAuthRefresher } from "./oauth/refresher.mjs";
9-
import { attachForwardProxy } from "./forward-proxy.mjs";
9+
import { attachForwardProxy, handleDownloadsAbsolute } from "./forward-proxy.mjs";
1010

1111
// Debug logging — writes to ~/.claude/cache-fix-debug.log (override path with
1212
// CACHE_FIX_DEBUG_LOG). Self-gated on CACHE_FIX_DEBUG=1; a no-op otherwise.
@@ -439,6 +439,29 @@ export function createProxyServer() {
439439
return originalEnd.apply(res, [chunk, ...args]);
440440
};
441441

442+
// RFC 7230 §5.3.2 absolute-form request-target. A proxy-configured
443+
// client does not always tunnel: axios's plain-proxy mode (the CLI's
444+
// auto-updater / telemetry paths) sends `GET https://host/path` on the
445+
// proxy connection instead of CONNECT. Only meaningful in forward mode
446+
// — reverse mode keeps its 404 contract for such targets (see below).
447+
if (_forwardActive > 0) {
448+
const abs = parseAbsoluteForm(req.url);
449+
if (abs) {
450+
// downloads.claude.ai with the rewrite active: same acceleration
451+
// as the CONNECT-MITM arrival style.
452+
if (handleDownloadsAbsolute(req, res, abs)) return;
453+
// Targets on the upstream reduce to origin-form so the normal
454+
// routing below (incl. the /v1/messages transform) applies.
455+
// Compare origins, not hostnames: scheme and port are part of
456+
// the authority (two servers on one host differ only by port).
457+
// Foreign targets fall through to handlePassthrough, where
458+
// buildUpstreamUrl honors the absolute-form authority.
459+
let upOrigin = "";
460+
try { upOrigin = new URL(config.upstream).origin; } catch {}
461+
if (abs.origin === upOrigin) req.url = abs.pathname + abs.search;
462+
}
463+
}
464+
442465
if (req.method === "GET" && req.url === "/health") return handleHealth(req, res);
443466
if (req.method === "POST" && req.url?.startsWith("/v1/messages")) return await handleMessages(req, res);
444467
if (req.url?.startsWith("/api/claude_cli/bootstrap")) return await handleBootstrap(req, res);

proxy/upstream.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,17 @@ export function getAgent(isHTTPS, hostname) {
186186
return agent;
187187
}
188188

189+
// RFC 7230 §5.3.2 absolute-form request-target. A client configured with
190+
// HTTP(S)_PROXY does not always tunnel: axios's built-in proxy mode (which the
191+
// Claude Code CLI's auto-updater and telemetry paths use) sends
192+
// `GET https://host/path HTTP/1.1` on the plain proxy connection instead of
193+
// issuing CONNECT. The authority inside that URI is the routing instruction.
194+
// Returns a URL for absolute-form targets, null for origin-form ones.
195+
export function parseAbsoluteForm(target) {
196+
if (!/^https?:\/\//i.test(target || "")) return null;
197+
try { return new URL(target); } catch { return null; }
198+
}
199+
189200
// Build the upstream URL by concatenating the configured base (with any path
190201
// component preserved) with the client request URL. The historical
191202
// `new URL(clientReq.url, base)` approach is RFC 3986 relative-resolution,
@@ -195,6 +206,13 @@ export function getAgent(isHTTPS, hostname) {
195206
// — the request would land at `https://corp-proxy.example.net/v1/messages`
196207
// with `/anthropic-mirror` silently dropped. See PR #188 / @nisqatsi.
197208
export function buildUpstreamUrl(base, clientUrl) {
209+
// Absolute-form carries its own authority — honor it. Concatenating it onto
210+
// the base misroutes the request to the upstream host
211+
// (`api.anthropic.com/https://downloads.claude.ai/...` → the upstream CDN
212+
// answers 404), which surfaces to the user as a permanent
213+
// "✘ Auto-update failed" banner plus failed telemetry exports.
214+
const abs = parseAbsoluteForm(clientUrl);
215+
if (abs) return abs;
198216
const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base;
199217
const relative = clientUrl.startsWith("/") ? clientUrl : "/" + clientUrl;
200218
return new URL(trimmedBase + relative);
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Tests for RFC 7230 §5.3.2 absolute-form request-targets in forward-proxy mode.
2+
//
3+
// A client configured with HTTP(S)_PROXY does not always tunnel: axios's
4+
// built-in proxy support (which the Claude Code CLI's auto-updater and
5+
// telemetry paths use) sends `GET https://host/path HTTP/1.1` on the plain
6+
// proxy connection instead of issuing CONNECT. A conforming HTTP proxy must
7+
// honor the authority in that request-target. The proxy instead treated the
8+
// absolute URI as an origin-form path and concatenated it onto the configured
9+
// upstream (`https://api.anthropic.com/https://downloads.claude.ai/...`),
10+
// misrouting every such request to the upstream host — Cloudflare answers 404
11+
// and the CLI renders a permanent "✘ Auto-update failed" banner (its 1P event
12+
// export and Datadog flush 404 the same way).
13+
//
14+
// Contract under test:
15+
// - forward mode, absolute-form to a FOREIGN host -> relayed to that host
16+
// - forward mode, absolute-form to the UPSTREAM -> behaves as origin-form
17+
// - reverse mode, absolute-form -> 404 (contract unchanged)
18+
19+
import { test } from "node:test";
20+
import assert from "node:assert/strict";
21+
import http from "node:http";
22+
import { mkdtempSync, rmSync } from "node:fs";
23+
import { tmpdir } from "node:os";
24+
import { join } from "node:path";
25+
26+
import { startProxy } from "../proxy/server.mjs";
27+
28+
const ENV_KEYS = [
29+
"CACHE_FIX_FORWARD_PROXY", "CACHE_FIX_CA_DIR", "CACHE_FIX_PROXY_UPSTREAM",
30+
"CACHE_FIX_HTTPS_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy",
31+
"NO_PROXY", "no_proxy",
32+
];
33+
34+
function saveEnv() {
35+
const saved = {};
36+
for (const k of ENV_KEYS) saved[k] = process.env[k];
37+
return saved;
38+
}
39+
function restoreEnv(saved) {
40+
for (const k of ENV_KEYS) {
41+
if (saved[k] === undefined) delete process.env[k];
42+
else process.env[k] = saved[k];
43+
}
44+
}
45+
46+
function listen(server) {
47+
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server.address().port)));
48+
}
49+
50+
// Send a raw absolute-form request THROUGH the proxy port: the request line's
51+
// target is the full URI, exactly what axios emits to a plain HTTP proxy.
52+
function absoluteFormRequest(proxyPort, method, absoluteUrl, body) {
53+
return new Promise((resolve, reject) => {
54+
const req = http.request(
55+
{ hostname: "127.0.0.1", port: proxyPort, method, path: absoluteUrl },
56+
(res) => {
57+
const chunks = [];
58+
res.on("data", (c) => chunks.push(c));
59+
res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
60+
}
61+
);
62+
req.on("error", reject);
63+
if (body) req.write(body);
64+
req.end();
65+
});
66+
}
67+
68+
test("forward mode: absolute-form to a foreign host is relayed to that host, not the upstream", async () => {
69+
const saved = saveEnv();
70+
const caDir = mkdtempSync(join(tmpdir(), "absform-ca-"));
71+
72+
const upstreamHits = [];
73+
const upstream = http.createServer((req, res) => {
74+
upstreamHits.push(req.url);
75+
res.writeHead(200, { "content-type": "application/json" });
76+
res.end("{}");
77+
});
78+
const upstreamPort = await listen(upstream);
79+
80+
const foreignHits = [];
81+
const foreign = http.createServer((req, res) => {
82+
foreignHits.push({ url: req.url, host: req.headers.host });
83+
res.writeHead(200, { "content-type": "text/plain" });
84+
res.end("2.1.999");
85+
});
86+
const foreignPort = await listen(foreign);
87+
88+
let handle;
89+
try {
90+
process.env.CACHE_FIX_FORWARD_PROXY = "on";
91+
process.env.CACHE_FIX_CA_DIR = caDir;
92+
process.env.CACHE_FIX_PROXY_UPSTREAM = `http://127.0.0.1:${upstreamPort}`;
93+
delete process.env.CACHE_FIX_HTTPS_PROXY;
94+
delete process.env.HTTPS_PROXY; delete process.env.HTTP_PROXY;
95+
delete process.env.https_proxy; delete process.env.http_proxy;
96+
delete process.env.NO_PROXY; delete process.env.no_proxy;
97+
98+
handle = await startProxy({ port: 0, watch: false });
99+
100+
const r = await absoluteFormRequest(
101+
handle.port, "GET", `http://127.0.0.1:${foreignPort}/claude-code-releases/latest`);
102+
103+
assert.equal(r.status, 200, "absolute-form request must reach its own target host");
104+
assert.equal(r.body, "2.1.999", "response body must stream back from the target");
105+
assert.equal(foreignHits.length, 1, "target host must be hit exactly once");
106+
assert.equal(foreignHits[0].url, "/claude-code-releases/latest",
107+
"target must receive the origin-form path, not the absolute URI");
108+
assert.deepEqual(upstreamHits, [],
109+
"the upstream must NOT see a foreign-host absolute-form request");
110+
} finally {
111+
restoreEnv(saved);
112+
if (handle) await handle.close();
113+
upstream.close();
114+
foreign.close();
115+
try { rmSync(caDir, { recursive: true, force: true }); } catch {}
116+
}
117+
});
118+
119+
test("forward mode: absolute-form to the upstream host behaves as origin-form (path + body intact)", async () => {
120+
const saved = saveEnv();
121+
const caDir = mkdtempSync(join(tmpdir(), "absform-up-ca-"));
122+
123+
const upstreamHits = [];
124+
const upstream = http.createServer((req, res) => {
125+
const chunks = [];
126+
req.on("data", (c) => chunks.push(c));
127+
req.on("end", () => {
128+
upstreamHits.push({ url: req.url, body: Buffer.concat(chunks).toString() });
129+
res.writeHead(200, { "content-type": "application/json" });
130+
res.end("{}");
131+
});
132+
});
133+
const upstreamPort = await listen(upstream);
134+
135+
let handle;
136+
try {
137+
process.env.CACHE_FIX_FORWARD_PROXY = "on";
138+
process.env.CACHE_FIX_CA_DIR = caDir;
139+
process.env.CACHE_FIX_PROXY_UPSTREAM = `http://127.0.0.1:${upstreamPort}`;
140+
delete process.env.CACHE_FIX_HTTPS_PROXY;
141+
delete process.env.HTTPS_PROXY; delete process.env.HTTP_PROXY;
142+
delete process.env.https_proxy; delete process.env.http_proxy;
143+
delete process.env.NO_PROXY; delete process.env.no_proxy;
144+
145+
handle = await startProxy({ port: 0, watch: false });
146+
147+
const r = await absoluteFormRequest(
148+
handle.port, "POST",
149+
`http://127.0.0.1:${upstreamPort}/api/event_logging/v2/batch`,
150+
'{"events":[]}');
151+
152+
assert.equal(r.status, 200);
153+
assert.equal(upstreamHits.length, 1, "upstream must be hit exactly once");
154+
assert.equal(upstreamHits[0].url, "/api/event_logging/v2/batch",
155+
"upstream must receive the origin-form path, not a concatenated absolute URI");
156+
assert.equal(upstreamHits[0].body, '{"events":[]}', "request body must pass through");
157+
} finally {
158+
restoreEnv(saved);
159+
if (handle) await handle.close();
160+
upstream.close();
161+
try { rmSync(caDir, { recursive: true, force: true }); } catch {}
162+
}
163+
});
164+
165+
test("reverse mode: absolute-form keeps the 404 contract (no relay)", async () => {
166+
const saved = saveEnv();
167+
168+
const foreignHits = [];
169+
const foreign = http.createServer((req, res) => {
170+
foreignHits.push(req.url);
171+
res.writeHead(200);
172+
res.end("nope");
173+
});
174+
const foreignPort = await listen(foreign);
175+
176+
let handle;
177+
try {
178+
delete process.env.CACHE_FIX_FORWARD_PROXY;
179+
delete process.env.CACHE_FIX_HTTPS_PROXY;
180+
delete process.env.HTTPS_PROXY; delete process.env.HTTP_PROXY;
181+
delete process.env.https_proxy; delete process.env.http_proxy;
182+
183+
handle = await startProxy({ port: 0, watch: false });
184+
185+
const r = await absoluteFormRequest(
186+
handle.port, "GET", `http://127.0.0.1:${foreignPort}/anything`);
187+
188+
assert.equal(r.status, 404, "reverse mode must not act as a forward proxy");
189+
assert.deepEqual(foreignHits, [], "reverse mode must not relay absolute-form requests");
190+
} finally {
191+
restoreEnv(saved);
192+
if (handle) await handle.close();
193+
foreign.close();
194+
}
195+
});

0 commit comments

Comments
 (0)