Skip to content

feat: proxy improvements#1455

Merged
pi0 merged 21 commits into
mainfrom
fix/proxy
Jul 11, 2026
Merged

feat: proxy improvements#1455
pi0 merged 21 commits into
mainfrom
fix/proxy

Conversation

@pi0x

@pi0x pi0x commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Improvements to the proxy / proxyRequest / fetchWithEvent utils, based on a comparison with httpxy's proxyFetch.

Fixes

  • Do not follow upstream redirects by default (8823fb7): proxy() now defaults redirect: "manual", so upstream 3xx responses are passed through to the client instead of undici silently following up to 20 hops. This also fixes a 502 when proxying a streamed request body to an upstream that redirects (the body cannot be replayed on redirect). Opt back in with fetchOptions: { redirect: "follow" }.
  • Header hygiene (4f9c446):
    • The client's accept header is forwarded again (it was in ignoredHeaders, breaking upstream content negotiation).
    • Hop-by-hop upstream response headers (connection, keep-alive, proxy-authenticate, proxy-connection, upgrade, trailer, te) are now stripped via a new ignoredResponseHeaders set.
    • forwardHeaders / filterHeaders now match header names case-insensitively.
  • Body forwarding (35cde0d):
    • Request bodies are forwarded based on presence instead of a PATCH/POST/PUT/DELETE/QUERY allowlist, so WebDAV-style (PROPFIND, REPORT, SEARCH) and custom methods keep their bodies (PayloadMethods removed as now-unused).
    • duplex is derived from the final body, so a stream supplied via fetchOptions.body on a body-less event no longer throws.
    • createSubRequest defaults duplex: "half" for stream bodies (fetchWithEvent path).

Features

  • timeout option (7fe8fcc): milliseconds to wait for the upstream response; composed into the existing signal handling via AbortSignal.timeout. Timeouts map to 504 Gateway Timeout instead of a generic 502. Client-disconnect (499) behavior is unchanged.
  • xfwd option (b9f83aa, 56a87a3): adds x-forwarded-for / x-forwarded-proto / x-forwarded-host / x-forwarded-port derived from event.req.ip and event.url. Each header is only set when absent — values already present on the incoming request are never modified. Default false.
  • location rewriting, like nginx proxy_redirect (874c5fb, 0ce0f9f): upstream location / refresh headers whose origin matches the proxy target are rewritten to the incoming request's origin, so client-side redirects keep flowing through the proxy instead of exposing the upstream host. Relative and third-party URLs (and internal / targets) are untouched. On by default; opt out with locationRewrite: false, or pass a record of prefix mappings (nginx proxy_redirect <from> <to>, e.g. { "https://upstream.example/two/": "/one/" }) to replace explicit URL prefixes instead — first match wins, and mappings also apply to internal targets.

Docs

  • Documented fetch-inherited limitations (db3ab5c): forced response decompression, host rewrite portability across runtimes, and the undici dispatcher escape hatch for unix sockets / TLS options / agents.

Review hardening (e2b0040)

A parallel security/correctness review of each changed area surfaced and fixed:

  • timeout now uses a cancelable timer cleared once the upstream responds — it can no longer truncate long-running body streams (SSE, large downloads); invalid values are sanitized instead of throwing RangeError.
  • timeout / client disconnects now also apply to internal (/) targets by racing event.app.fetch() against the request signal (H3.fetch does not observe it); a pre-aborted client short-circuits to 499 without dispatching the sub-request.
  • An explicitly undefined fetchOptions.redirect can no longer silently restore fetch's follow default; browser/service-worker opaque-redirect responses fail loudly with 502 instead of an empty 200.
  • Body forwarding is gated on the outgoing (possibly overridden) method, and a stale incoming content-length is dropped when the forwarded body differs (fetch trusts a supplied content-length verbatim — framing desync).
  • Response headers nominated by the upstream Connection header are stripped (RFC 7230 §6.1); set-cookie is collected via getSetCookie(); request te/trailer are no longer forwarded.
  • locationRewrite also rewrites protocol-relative locations pointing at the target (upstream host leak) and handles non-canonical refresh shapes (optional delay, padded =, quoted URLs); prefix maps iterate own keys only.
  • Docs: xfwd spoofing warning (client-supplied x-forwarded-for wins; strip via filterHeaders on internet-facing edges) + proxyRequest-only note, forwardHeaders denylist-bypass caveat, propagateAbortError/timeout consistency, browser/SW redirect caveat.

The review confirmed the rest sound: no open-redirect in the location rewrite (origin re-rooting verified against //, userinfo, and backslash tricks), correct 499-vs-504 signal classification, no smuggling surface (undici re-frames; hop-by-hop stripped), and AbortSignal.any/URL.canParse/getSetCookie availability on engines.node >= 20.11.1 and other supported runtimes.

Notes

  • Every fix comes with a regression test that was confirmed failing before the change; full suite passes (1383 tests, no type errors) and lint is clean.
  • The branch also fast-forwards over the latest main commits since the work was based on main's tip.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Enhanced X-Forwarded-* handling when enabled (adds missing values; does not override an existing x-forwarded-for).
  • Bug Fixes
    • Upstream 3xx responses now pass through correctly (no automatic following); redirect behavior is documented and respects manual redirect mode.
    • Improved streamed request handling and duplex defaults; proxy timeouts reliably return 504 Gateway Timeout.
    • Case-insensitive header filtering plus stricter hop-by-hop response header stripping (including connection-driven headers); correct accept/accept-encoding forwarding.
  • Documentation
    • Clarified redirect pass-through/follow rules and location/refresh rewrite behavior (including limitations with streamed bodies).
  • Tests
    • Expanded coverage for redirects, streaming/duplex, header rules, xfwd, timeout-to-504, and location/refresh rewriting.

pi0 and others added 10 commits July 11, 2026 12:01
`proxy()` and `proxyRequest()` called `fetch(target, ...)` without a
`redirect` option, so undici's default `redirect: "follow"` silently
followed up to 20 upstream 3xx hops. A reverse proxy should instead pass
3xx responses through to the client. Additionally, `proxyRequest()`
streams the request body (`duplex: "half"`), which fetch cannot replay on
a redirect — so proxying a POST to an upstream returning 3xx errored and
surfaced as a 502.

Default `fetchOptions.redirect` to `"manual"` (placed before the spread so
an explicit caller value still wins), and document the pass-through
behavior on `proxy`, `proxyRequest`, and `ProxyOptions`. Internal
sub-requests via `event.app.fetch()` are unaffected — they never follow
redirects.

Co-Authored-By: Claude Fable 5 <[email protected]>
Add a `timeout` option to `ProxyOptions` that composes an
`AbortSignal.timeout(timeout)` into the request's abort signal so a slow
upstream is aborted instead of hanging. A fired timeout aborts with a
`TimeoutError`, which is now mapped to `504 Gateway Timeout` in the catch
block, before the existing `AbortError`/`499` client-disconnect handling
(which is left unchanged). A caller-supplied `AbortSignal.timeout` also
maps to 504.

Co-Authored-By: Claude Fable 5 <[email protected]>
… headers, case-insensitive filters)

- Stop stripping the client's `accept` request header so upstream content
  negotiation (JSON vs HTML) keeps working behind the proxy. `accept-encoding`
  stays stripped since fetch auto-decompresses.
- Strip hop-by-hop headers from the upstream response via a new exported
  `ignoredResponseHeaders` set (connection, keep-alive, proxy-authenticate,
  proxy-connection, upgrade, trailer, te + the existing content-length/
  content-encoding/transfer-encoding) instead of chained comparisons.
- Match `forwardHeaders`/`filterHeaders` case-insensitively by lowercasing the
  option arrays once, so `["X-Custom"]` matches the lowercased header names from
  `Headers.entries()`. Documented in the ProxyOptions JSDoc.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ling

Previously `proxyRequest()` only forwarded a request body when the method was
in a hardcoded allowlist (PATCH/POST/PUT/DELETE/QUERY). Bodies on WebDAV-style
methods (PROPFIND/REPORT/SEARCH) and custom methods were silently dropped.

- Fix A: gate body forwarding on body presence instead of a method allowlist —
  forward `event.req.body` whenever it is non-null and the method is not
  GET/HEAD (fetch rejects bodies on those). Remove the now-unused
  `PayloadMethods` set from `src/utils/internal/proxy.ts`.
- Fix B: derive `duplex` from the final effective body
  (`opts.fetchOptions.body ?? requestBody`) after the spread, so a streamed
  body supplied via `fetchOptions` on a body-less (GET) event still sets
  `duplex` and fetch does not throw. An explicit `fetchOptions.duplex` wins.
- Fix C: in `createSubRequest`, default `duplex: "half"` when a body is present
  and no duplex is set, so streamed bodies via `fetchWithEvent` don't throw in
  the Request constructor on Node.

Adds regression tests covering a custom-method (REPORT) body, a stream body
supplied via fetchOptions on a GET event, and fetchWithEvent with a stream body.

Co-Authored-By: Claude Fable 5 <[email protected]>
Add an `xfwd` option to `ProxyOptions` (default `false`). When enabled,
`proxyRequest()` adds standard `x-forwarded-*` request headers derived from
the incoming request so the upstream learns the real client and origin:

- `x-forwarded-for`: appends `event.req.ip` to any existing value
- `x-forwarded-proto`: appends the incoming request protocol
- `x-forwarded-host`: original host (incl. port), set only when absent
- `x-forwarded-port`: original port or protocol default, set only when absent

Logic lives in a small internal `applyXForwardedHeaders` helper that
normalizes the merged headers (plain object or `Headers`) to `Headers`.

Co-Authored-By: Claude Fable 5 <[email protected]>
# Conflicts:
#	src/utils/internal/proxy.ts
#	src/utils/proxy.ts
#	test/proxy.test.ts
Note forced response decompression, host header rewrite portability, and
the undici dispatcher escape hatch for sockets/TLS/agents.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x
pi0x requested a review from pi0 as a code owner July 11, 2026 12:07
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Proxy forwarding now preserves accept, supports custom-method and streamed bodies, derives forwarded headers, passes redirects through by default, composes abort signals, maps timeouts to 504, rewrites eligible locations, and filters response headers. Documentation and tests cover these behaviors.

Changes

Proxy behavior

Layer / File(s) Summary
Header forwarding and filtering
src/utils/internal/proxy.ts, src/utils/proxy.ts, test/proxy.test.ts, test/unit/proxy.test.ts
Request and response header filtering is case-insensitive, preserves accept, derives x-forwarded-* headers, and validates hop-by-hop header removal.
Request body and duplex handling
src/utils/proxy.ts, test/proxy.test.ts
Bodies are forwarded for custom methods and stream overrides, with duplex: "half" derived when needed.
Redirects, signals, timeouts, and location rewriting
src/utils/proxy.ts, docs/2.utils/5.proxy.md, test/proxy.test.ts
Redirects pass through by default, abort signals are composed, timeout failures return 504, eligible location headers are rewritten, and documentation/tests describe the behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • h3js/h3#1248: Overlapping response-header filtering for hop-by-hop and transfer-encoding headers.
  • h3js/h3#1417: Overlapping proxy abort-signal composition and error handling.
  • h3js/h3#1445: Overlapping proxy request-method and body forwarding changes.

Suggested reviewers: pi0

Poem

A rabbit hops through headers bright,
Carries streams by moonlit light.
Redirects pause, timeouts bow,
Five-oh-four helps signals now.
Proxy paths grow clear and neat. 🐇

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is related, but too generic to convey the main proxy changes in this PR. Use a specific title that highlights the primary change, such as redirect handling, header forwarding, or timeout support.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/proxy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Never modify values already present on the incoming request (or set via
header options) — no append semantics.

Co-Authored-By: Claude Fable 5 <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/proxy.ts`:
- Around line 121-125: Update the requestBody guard near the derived method
declaration to use method instead of event.req.method when excluding GET and
HEAD bodies. Preserve the existing body presence check and ensure overridden
outgoing GET/HEAD requests remain bodyless.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4c8ea06d-0231-42b9-9085-5421a0f4abbc

📥 Commits

Reviewing files that changed from the base of the PR and between 3813e9b and 7af282f.

📒 Files selected for processing (5)
  • docs/2.utils/5.proxy.md
  • src/utils/internal/proxy.ts
  • src/utils/proxy.ts
  • test/proxy.test.ts
  • test/unit/proxy.test.ts

Comment thread src/utils/proxy.ts
pi0 and others added 2 commits July 11, 2026 12:33
Upstream location and refresh headers whose origin matches the proxy
target are rewritten to the incoming request's origin, so client-side
redirects keep flowing through the proxy instead of exposing (and
sending the browser to) the upstream host. Relative and third-party
URLs are untouched. Opt out with `locationRewrite: false`.

Co-Authored-By: Claude Fable 5 <[email protected]>
Like nginx `proxy_redirect <from> <to>`, a record maps location/refresh
URL prefixes to replacements; the first matching prefix wins and only
explicit mappings apply (including for internal targets).

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0 pi0 changed the title fix(proxy): redirect pass-through, header hygiene, body forwarding + timeout/xfwd options feat: proxy improvements Jul 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/proxy.test.ts (1)

729-729: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use it.skipIf instead of it.runIf for runtime-specific test skips.

The coding guidelines for test files specify using it.skipIf(ctx.target === "node") for runtime-specific skips. This test uses it.runIf(t.target === "web"), which is functionally equivalent but deviates from the prescribed API.

♻️ Proposed refactor
-        it.runIf(t.target === "web")(
+        it.skipIf(t.target === "node")(
           "replaces the first matching prefix with a locationRewrite record",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/proxy.test.ts` at line 729, Update the runtime-specific test declaration
using it.runIf in the affected test block to use it.skipIf with the equivalent
node-target condition, preserving execution on web and skipping on node
according to the test-file convention.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/proxy.test.ts`:
- Line 729: Update the runtime-specific test declaration using it.runIf in the
affected test block to use it.skipIf with the equivalent node-target condition,
preserving execution on web and skipping on node according to the test-file
convention.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 42c0a60d-6e6e-4e85-b618-22bae2def949

📥 Commits

Reviewing files that changed from the base of the PR and between 874c5fb and 0ce0f9f.

📒 Files selected for processing (3)
  • src/utils/internal/proxy.ts
  • src/utils/proxy.ts
  • test/proxy.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/utils/internal/proxy.ts
  • src/utils/proxy.ts

pi0 and others added 7 commits July 11, 2026 12:56
- timeout: use a cancelable timer cleared once the upstream responds, so
  it can no longer truncate long-running body streams; sanitize invalid
  values instead of throwing RangeError
- timeout/disconnect: race internal (/) sub-requests against the request
  signal (H3.fetch does not observe it) so 504/499 work there too; a
  pre-aborted client no longer dispatches the sub-request
- redirect: an explicitly undefined fetchOptions.redirect can no longer
  silently restore fetch's follow default; opaque-redirect responses
  (browser/service-worker manual mode) now fail loudly with 502
- body: gate forwarding on the outgoing (possibly overridden) method, and
  drop the stale incoming content-length when the forwarded body differs
- headers: strip response headers nominated by the upstream connection
  header (RFC 7230 6.1), collect set-cookie via getSetCookie(), stop
  forwarding request te/trailer
- locationRewrite: rewrite protocol-relative locations pointing at the
  target (upstream host leak), widen refresh matching to optional-delay/
  padded/quoted shapes, iterate prefix maps with Object.keys
- docs: xfwd spoofing warning + proxyRequest-only note, fix stale
  internal xfwd comment, forwardHeaders denylist-bypass caveat,
  propagateAbortError/timeout consistency, browser/SW redirect caveat

Co-Authored-By: Claude Fable 5 <[email protected]>
…logic

- proxy: clamp a positive `timeout` up to at least 1ms so a sub-millisecond
  value no longer `Math.trunc`s to 0, firing `setTimeout(..., 0)` and 504-ing
  every request before the upstream can respond
- proxy: document that an already-disconnected client skips dispatch of an
  internal ("/") sub-request handler (side effects do not run)
- cache/static: extract the RFC 9110 §13.1.3 conditional-request precedence
  into a shared `isCacheMatch` helper so serveStatic and handleCacheHeaders
  can't diverge

Co-Authored-By: Claude Fable 5 <[email protected]>
…esponses

Address a second-round review (ISSUES.md):

- Strip incoming request headers nominated by the client's `Connection`
  header (RFC 9110 §7.6.1), and add `proxy-authorization`/`proxy-connection`
  to the default request denylist so proxy credentials aren't leaked upstream.
  Extract a shared `connectionTokens` helper for both directions.
- Classify aborts by the composed signal's reason instead of only
  `error.name === "AbortError"`, so a client disconnect that aborts with a
  raw socket error (e.g. srvx-on-Node `ECONNRESET`) still yields a quiet 499
  rather than a 502.
- Reject unrelayable opaque/error/status-0 fetch responses with a 502 instead
  of surfacing an empty `200 OK` via `HTTPResponse`'s `status || 200`.
- Use own-property checks in `rewriteCookieProperty` so a polluted prototype
  can't inject phantom domain/path rewrite rules (matches `rewritePrefix`).
- `fetchWithEvent`: accept a `duplex` init, default it for streamed external
  bodies, and clarify that external URLs use native fetch without inheriting
  event headers/context.
- Document that `forwardHeaders` is a denylist-bypass (not an exclusive
  allowlist) and that `x-forwarded-proto`/`-host` derive from `event.url`.

Co-Authored-By: Claude Fable 5 <[email protected]>
srvx now gates `X-Forwarded-*` behind an opt-in `trustProxy` (srvx#223), so by
default `event.url` reflects the real transport and on-the-wire `Host` — a
client can no longer spoof `x-forwarded-proto`/`-host` into it. Reword the
`xfwd` security note accordingly: the values follow inbound forwarded headers
only when the server is configured to trust an upstream proxy.

Co-Authored-By: Claude Fable 5 <[email protected]>
@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.84615% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/utils/internal/proxy.ts 92.53% 5 Missing ⚠️
src/utils/proxy.ts 94.33% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@pi0
pi0 merged commit 7a37962 into main Jul 11, 2026
10 checks passed
@pi0
pi0 deleted the fix/proxy branch July 11, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants