Skip to content

fix(forward-proxy): honor RFC 7230 absolute-form request-targets - #261

Merged
vsits-proxy-builder[bot] merged 1 commit into
cnighswonger:mainfrom
codeslake:fix/forward-absolute-form
Jul 31, 2026
Merged

fix(forward-proxy): honor RFC 7230 absolute-form request-targets#261
vsits-proxy-builder[bot] merged 1 commit into
cnighswonger:mainfrom
codeslake:fix/forward-absolute-form

Conversation

@codeslake

Copy link
Copy Markdown
Contributor

Problem

A client configured with HTTP(S)_PROXY does not always tunnel. axios's
built-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:

GET https://downloads.claude.ai/claude-code-releases/latest HTTP/1.1

In forward-proxy mode these requests land in handlePassthrough, and
buildUpstreamUrl() treats the absolute URI as an origin-form path,
concatenating it onto the configured upstream:

https://api.anthropic.com/https://downloads.claude.ai/claude-code-releases/latest

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.json on a later success, a single failure pins a
permanent ✘ Auto-update failed · Run claude doctor banner on the
statusline. Telemetry (1P events, Datadog) 404s through the same
mechanism.

This is easy to misdiagnose: curl -x <proxy> and claude update both
use 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 agents boot:

*** PLAIN *** GET https://downloads.claude.ai/claude-code-releases/latest HTTP/1.1
*** PLAIN *** POST https://api.anthropic.com/api/event_logging/v2/batch HTTP/1.1
CONNECT github.com:443 HTTP/1.1          <- other subsystems tunnel fine

Fix

Honor the authority carried inside an absolute-form request-target.

  • proxy/upstream.mjs — new parseAbsoluteForm() (returns a URL
    for http(s)://… targets, null for 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 targets
    before routing: targets on the upstream origin reduce to origin-form
    (so /v1/messages still gets the cache transform), downloads.claude.ai
    routes through the existing download rewrite, and foreign hosts fall
    through to handlePassthrough, which now relays them to their real
    destination. Reverse mode never enters this branch and keeps its 404
    contract.
  • proxy/forward-proxy.mjshandleDownloadsAbsolute(), a 12-line
    adapter feeding absolute-form downloads into the same
    handleDownloadsRequest the CONNECT-MITM path uses, so both arrival
    styles get the storage acceleration.
Request Before After
forward, absolute-form -> foreign host relayed to upstream -> 404 relayed to that host
forward, absolute-form -> upstream itself path mangled -> 404 behaves as origin-form (incl. /v1/messages transform)
forward, absolute-form -> downloads (rewrite on) 404 storage rewrite, origin fallback
forward, origin-form (RC creds, OAuth, /v1/messages) unchanged
reverse mode, any absolute-form 404 404 (unchanged, test-pinned)

Testing

  • New test/proxy-forward-absolute-form.test.mjs (3 tests, written
    red-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.
  • Full suite: 1379 tests, 1377 pass. The one failure
    (proxy-server.test.mjs "routes to upstream", expects a live 401/502
    from 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.
  • Live A/B on macOS, CC 2.1.214: a claude agents view through the
    unpatched 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/messages caching still
    works. Deployed on our downstream since 2026-07-18 with a live fleet —
    no regressions observed.

Scope / risk notes

  • No new trust surface: the proxy already blind-tunnels CONNECT to
    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.
  • Origin comparison uses URL.origin (scheme+host+port), not hostname,
    so two servers on one host can't be conflated.
  • No config, no new env flags; behavior only changes for requests that
    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

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>

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@vsits-codex-review-agent vsits-codex-review-agent Bot added reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings approved-by-codex-agent Final implementation approval from Codex Agent labels Jul 31, 2026
@vsits-proxy-builder
vsits-proxy-builder Bot merged commit 8b25dc9 into cnighswonger:main Jul 31, 2026
5 checks passed
cnighswonger pushed a commit that referenced this pull request Jul 31, 2026
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>
codeslake added a commit to codeslake/claude-code-cache-fix that referenced this pull request Aug 1, 2026
…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.
codeslake added a commit to codeslake/claude-code-cache-fix that referenced this pull request Aug 1, 2026
…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.
codeslake added a commit to codeslake/claude-code-cache-fix that referenced this pull request Aug 1, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved-by-codex-agent Final implementation approval from Codex Agent reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant