fix(forward-proxy): honor RFC 7230 absolute-form request-targets - #261
Conversation
A proxy-configured client 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. buildUpstreamUrl() treated that absolute URI as an origin-form path and concatenated it onto the configured upstream (`api.anthropic.com/https://downloads.claude.ai/...`), misrouting every such request to the upstream host. Cloudflare answers 404, the CLI's version check fails 3.5s after every session start, and the user gets a permanent "✘ Auto-update failed" banner (1P event export and Datadog flush 404 the same way). - upstream.mjs: parseAbsoluteForm() + honor the absolute-form authority in buildUpstreamUrl(); origin-form concatenation (PR cnighswonger#188 contract) unchanged. - server.mjs: forward mode normalizes absolute-form targets — upstream- origin targets reduce to origin-form so the normal routing (incl. the /v1/messages transform) applies; downloads.claude.ai routes through the existing rewrite; foreign hosts relay via handlePassthrough. Reverse mode keeps its 404 contract. - forward-proxy.mjs: handleDownloadsAbsolute() adapter so both arrival styles (CONNECT-MITM and absolute-form) get the download rewrite. Verified live A/B on macOS (CC 2.1.214): an agents view through the buggy proxy renders the banner with two 404s in the debug log; the same launch through the fixed proxy renders no banner and zero 404s, and the /v1/messages cache transform still applies. Full suite: 1379 tests, 1377 pass; the one failure (proxy-server.test.mjs "routes to upstream") reproduces identically on the unmodified base — corp-network artifact (direct egress 403s), same hygiene class as cnighswonger#253 item 3. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Review: PR #261 forward-proxy absolute-form request-targets
Date: 2026-07-31
Reviewed: PR head 65360ae0 merged onto origin/main 0770147 (local merge 8b1a06b)
Round: 1
Label applied: approved-by-codex-agent
What Is Correct
The #274 header-wrapper change and this PR are semantically independent in the merged tree. Forward-mode absolute-form normalization happens before route dispatch in proxy/server.mjs:442-467, so an upstream-origin absolute-form target is reduced to origin-form before handleMessages() is selected. handleMessages() then passes the post-preForward wrapper object with url: clientReq.url into forwardRequest() (proxy/server.mjs:148-157), and forwardRequest() still reads only .url/.method/.headers (proxy/upstream.mjs:221-239). I verified that exact merged path with an end-to-end probe against POST http://<upstream>/v1/messages?beta=true: the upstream received /v1/messages?beta=true, not an absolute URI or concatenated path.
The new foreign-host behavior is consistent with the existing forward-proxy contract, not a new transport class. Forward mode already blind-tunnels arbitrary non-upstream CONNECT targets in proxy/forward-proxy.mjs:248-266 and proxy/forward-proxy.mjs:577; this PR adds RFC 7230 absolute-form parity for clients that skip CONNECT by letting handlePassthrough() forward the original absolute-form request and letting buildUpstreamUrl() honor that authority (proxy/server.mjs:375-385, proxy/upstream.mjs:195-218). The proxy still binds to loopback by default (proxy/config.mjs:27-29).
The #188 base-path-preserving contract is intact. The origin-form branch in buildUpstreamUrl() is unchanged aside from the new absolute-form early return, and the existing regression table in test/proxy-upstream-corp-proxy.test.mjs:73-137 still passes on the merged tree.
Reverse mode keeps its 404 contract. The normalization branch is gated on _forwardActive > 0 in proxy/server.mjs:447, and test/proxy-forward-absolute-form.test.mjs:165-190 pins that reverse-mode absolute-form requests do not relay.
Test coverage is adequate for the changed execution path. The new table in test/proxy-forward-absolute-form.test.mjs:68-190 covers foreign-host relay, upstream-host normalization, and reverse-mode 404; I also ran the full merged suite successfully.
Blockers
None.
What Needs Attention
There is still no host allowlist for forward mode absolute-form relay, so any local client that can reach the proxy can send an absolute-form request to an arbitrary host, and forwardRequest() will forward end-to-end headers except hop-by-hop/proxy headers (proxy/upstream.mjs:223-239). That means a misbehaving local client could leak its own authorization, cookie, or x-api-key headers to a foreign host. I do not consider that a PR-specific blocker because forward mode already exposes an arbitrary-host CONNECT relay and still binds to 127.0.0.1 by default, but it remains a real hardening gap if the project ever wants this proxy to be narrower than a general loopback forward proxy.
Bloat / Non-Functional
None.
Recommendations
If you want to reduce the relay surface later, treat it as a separate forward-proxy hardening change: explicit host allowlisting for non-upstream absolute-form traffic, or an authentication gate when binding off-loopback, would be the relevant controls.
Bottom Line
Approve. On current origin/main, the absolute-form fix composes cleanly with #274, preserves the #188 origin-form contract, preserves reverse-mode 404 behavior, and passes the full merged test suite (1440/1440). The security posture is unchanged in kind from the existing forward-proxy design, though still worth a follow-up hardening issue if a narrower relay contract is desired. — Codex review
The global reviewer bar is "larger than the directive's requirements justify." Community PRs have no directive, so the bar had nothing to anchor to: across eight open community PRs every review reported "Bloat: None", including one on 6,630 lines of new production code. AGENTS.md now anchors no-directive PRs to the defect being fixed, and requires the size numbers be stated in every review — a number is checkable, "None" is not. Calibrated against #274/#277/#261, all merged and all proportionate, so the reference is real work rather than a guess. Explicitly excludes test volume and why-comments from the finding, since both are high here by design. CONTRIBUTING.md is new: AGENTS.md and CLAUDE.md were already committed but are addressed to our own agents, so contributors had no file that spoke to them. Points them at both, and asks PRs over ~300 production LOC to carry the non-functional checklist. Claude-Session: https://claude.ai/code/session_01GvZKP1JeXgHFCovTaAPT5B Co-authored-by: vsits-proxy-builder[bot] <223447982+vsits-proxy-builder[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nighswonger#283 pending) integrated = upstream/main + every one of our still-open upstream PRs. Rebuilt from upstream/main, not cherry-picked onto the old integrated, so the branch stays reproducible from its inputs. cnighswonger#261 (absolute-form request-targets) is NO LONGER merged here: upstream took it as 8b25dc9 on 2026-07-31, so it arrives through upstream/main and merging the branch again would only replay it.
…nighswonger#283 pending) integrated = upstream/main + every one of our still-open upstream PRs. Rebuilt from upstream/main, not cherry-picked onto the old integrated, so the branch stays reproducible from its inputs. cnighswonger#261 (absolute-form request-targets) is NO LONGER merged here: upstream took it as 8b25dc9 on 2026-07-31, so it arrives through upstream/main and merging the branch again would only replay it.
…nighswonger#283 pending) integrated = upstream/main + every one of our still-open upstream PRs. Rebuilt from upstream/main, not cherry-picked onto the old integrated, so the branch stays reproducible from its inputs. cnighswonger#261 (absolute-form request-targets) is NO LONGER merged here: upstream took it as 8b25dc9 on 2026-07-31, so it arrives through upstream/main and merging the branch again would only replay it.
Problem
A client configured with
HTTP(S)_PROXYdoes not always tunnel. axios'sbuilt-in proxy mode — which the Claude Code CLI uses for its auto-updater,
1P event export, and Datadog log flush — skips CONNECT and sends the
request in absolute-form (RFC 7230 §5.3.2) on the plain proxy
connection:
In forward-proxy mode these requests land in
handlePassthrough, andbuildUpstreamUrl()treats the absolute URI as an origin-form path,concatenating it onto the configured upstream:
Cloudflare answers 404. The CLI's version check therefore fails ~3.5s
after every session start, and because the CLI never rewrites
.last-update-result.jsonon a later success, a single failure pins apermanent
✘ Auto-update failed · Run claude doctorbanner on thestatusline. Telemetry (1P events, Datadog) 404s through the same
mechanism.
This is easy to misdiagnose:
curl -x <proxy>andclaude updatebothuse CONNECT and succeed against the same proxy, so everything looks
healthy except live sessions. Wire-captured with a tap proxy in front of
a real
claude agentsboot:Fix
Honor the authority carried inside an absolute-form request-target.
proxy/upstream.mjs— newparseAbsoluteForm()(returns aURLfor
http(s)://…targets,nullfor origin-form);buildUpstreamUrl()returns that URL directly when present. Origin-form concatenation is
untouched — the Fix upstream url forming on forwardRequest #188 base-path-preserving contract holds byte-for-byte
(its regression table still passes).
proxy/server.mjs— forward mode normalizes absolute-form targetsbefore routing: targets on the upstream origin reduce to origin-form
(so
/v1/messagesstill gets the cache transform),downloads.claude.airoutes through the existing download rewrite, and foreign hosts fall
through to
handlePassthrough, which now relays them to their realdestination. Reverse mode never enters this branch and keeps its 404
contract.
proxy/forward-proxy.mjs—handleDownloadsAbsolute(), a 12-lineadapter feeding absolute-form downloads into the same
handleDownloadsRequestthe CONNECT-MITM path uses, so both arrivalstyles get the storage acceleration.
/v1/messagestransform)/v1/messages)Testing
test/proxy-forward-absolute-form.test.mjs(3 tests, writtenred-first against the bug): foreign-host relay reaches the target and
never touches the upstream; upstream-origin absolute-form preserves
path + body; reverse mode keeps the 404 contract.
(
proxy-server.test.mjs"routes to upstream", expects a live 401/502from api.anthropic.com) reproduces identically on the unmodified base —
corp-network artifact (direct egress is 403'd here), same hermeticity
class as forward-proxy (#251): non-blocking cleanup items from live validation #253 item 3.
claude agentsview through theunpatched proxy renders the banner with two 404s in its debug log; the
identical launch through the patched proxy renders no banner, zero 404s,
version check returns
200 "2.1.214", and/v1/messagescaching stillworks. Deployed on our downstream since 2026-07-18 with a live fleet —
no regressions observed.
Scope / risk notes
arbitrary hosts (Add opt-in forward-proxy mode to keep Remote Control (implements #248) #251) and binds to 127.0.0.1; honoring absolute-form
stays inside that same client-trust model.
URL.origin(scheme+host+port), not hostname,so two servers on one host can't be conflated.
were previously mis-routed to a guaranteed 404.
Closes the field failure behind the permanent "Auto-update failed" banner
for forward-proxy users.
🤖 Generated with Claude Code