From b520dbb093b6b103331445e974a1cdac34091192 Mon Sep 17 00:00:00 2001 From: SHAIK VAHID <38548782+vahidshaik1901@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:15:17 +0530 Subject: [PATCH] fix(vite-plugin): preserve the request authority under HTTP/2 Browsers negotiate HTTP/2 whenever `server.https` is enabled, and HTTP/2 carries the authority in the `:authority` pseudo-header rather than in `Host`. `createHeaders()` from `@remix-run/node-fetch-server` skips every `:`-prefixed pseudo-header, so `Host` was absent from the parsed headers and `createRequestForIncomingMessage()` fell through to the literal `"localhost"`, dropping both the host and the port. A Worker served from `vite dev --https` on port 5173 therefore saw `https://localhost/` rather than `https://localhost:5173/`. `toMiniflareRequest()` read the same missing `Host` header, so `X-Forwarded-Host` was never set either. Auth libraries that rebuild redirect URLs from `request.url` or the forwarded headers, such as Clerk's handshake flow, redirected to the wrong origin and looped. Resolve the authority from `Host` first and `:authority` second via a new `getRequestHost()` helper, mirroring the existing `getForwardedProto()`, and fall back to the host of the already-resolved request URL when setting `X-Forwarded-Host`. Covered by tests against a cleartext HTTP/2 server, which exercises the same code path without requiring TLS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018W34ieE3NNGaRQBxj5CKkH --- .changeset/http2-authority-host-resolution.md | 9 +++ .../src/__tests__/utils.spec.ts | 76 +++++++++++++++++++ packages/vite-plugin-cloudflare/src/utils.ts | 26 ++++++- 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 .changeset/http2-authority-host-resolution.md diff --git a/.changeset/http2-authority-host-resolution.md b/.changeset/http2-authority-host-resolution.md new file mode 100644 index 00000000000..fa8fbd3abb9 --- /dev/null +++ b/.changeset/http2-authority-host-resolution.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Preserve the request authority under HTTP/2 so `request.url` keeps its port + +Browsers negotiate HTTP/2 whenever `server.https` is enabled, and HTTP/2 carries the authority in the `:authority` pseudo-header rather than in `Host`. Pseudo-headers are skipped when the Fetch `Headers` are built, so `Host` was absent and request construction fell back to a bare `"localhost"`, dropping the host and port. A Worker running behind `vite dev --https` on port 5173 therefore saw `https://localhost/` instead of `https://localhost:5173/`, and `X-Forwarded-Host` was never set at all. + +The authority is now read from `Host` first and from `:authority` second, and `X-Forwarded-Host` falls back to the host of the resolved request URL when no `Host` header is present. Auth libraries that rebuild redirect URLs from `request.url` or the forwarded headers — such as Clerk's handshake flow, which previously redirected to the wrong origin and looped — keep the correct port over HTTPS. diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index cfe6729887e..ae3af852527 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts @@ -1,4 +1,5 @@ import http from "node:http"; +import http2 from "node:http2"; import net from "node:net"; import * as path from "node:path"; import { Response as MiniflareResponse } from "miniflare"; @@ -368,3 +369,78 @@ describe("createRequestHandler", () => { } }); }); + +describe("createRequestHandler over HTTP/2", () => { + // Browsers use HTTP/2 whenever `server.https` is enabled, and HTTP/2 carries + // the authority in the `:authority` pseudo-header rather than in `Host`. + // A cleartext (h2c) server exercises the same code path without needing TLS. + let h2Server: http2.Http2Server; + let h2Port: number; + let capturedUrls: string[]; + let capturedForwardedHosts: (string | null)[]; + + afterEach(async () => { + await new Promise((resolve, reject) => + h2Server?.close((e) => (e ? reject(e) : resolve())) + ); + }); + + function startH2Server() { + capturedUrls = []; + capturedForwardedHosts = []; + const handler = createRequestHandler(async (request) => { + capturedUrls.push(request.url); + capturedForwardedHosts.push(request.headers.get("X-Forwarded-Host")); + return new MiniflareResponse("OK"); + }); + + h2Server = http2.createServer((req, res) => { + void handler( + req as unknown as Parameters[0], + res as unknown as Parameters[1], + (error: unknown) => { + res.statusCode = 500; + res.end(error instanceof Error ? error.message : String(error)); + } + ); + }); + + return new Promise((r) => + h2Server.listen(0, "127.0.0.1", () => { + h2Port = (h2Server.address() as AddressInfo).port; + r(); + }) + ); + } + + async function h2Get(pathname: string) { + const client = http2.connect(`http://127.0.0.1:${h2Port}`); + try { + await new Promise((resolve, reject) => { + const req = client.request({ ":path": pathname, ":method": "GET" }); + req.on("response", () => req.resume()); + req.on("end", () => resolve()); + req.on("error", reject); + req.end(); + }); + } finally { + client.close(); + } + } + + test("keeps the authority, including the port, in `request.url`", async ({ + expect, + }) => { + await startH2Server(); + await h2Get("/path"); + expect(capturedUrls[0]).toBe(`http://127.0.0.1:${h2Port}/path`); + }); + + test("sets `X-Forwarded-Host` from the `:authority` pseudo-header", async ({ + expect, + }) => { + await startH2Server(); + await h2Get("/path"); + expect(capturedForwardedHosts[0]).toBe(`127.0.0.1:${h2Port}`); + }); +}); diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index f30ca3dc063..b448706839b 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -145,7 +145,7 @@ function createRequestForIncomingMessage( const protocol = options?.protocol ?? ("encrypted" in req.socket && req.socket.encrypted ? "https:" : "http:"); - const host = options?.host ?? headers.get("Host") ?? "localhost"; + const host = options?.host ?? getRequestHost(req) ?? "localhost"; const url = new URL(req.url ?? "/", `${protocol}//${host}`); const init: RequestInit & { duplex?: "half" } = { method, @@ -213,7 +213,9 @@ function createCancellableRequestBody( } function toMiniflareRequest(request: Request): MiniflareRequest { - const host = request.headers.get("Host"); + // Under HTTP/2 there is no `Host` header, but the request URL was built from + // the resolved authority, so it still carries the host and port. + const host = request.headers.get("Host") ?? new URL(request.url).host; const xForwardedHost = request.headers.get("X-Forwarded-Host"); if (host && !xForwardedHost) { @@ -243,6 +245,26 @@ function toMiniflareRequest(request: Request): MiniflareRequest { export const isRolldown = "rolldownVersion" in vite; +/** + * Resolves the authority (host and port) of an incoming Node.js request. + * + * HTTP/1.1 carries it in the `Host` header, but HTTP/2 — which browsers use + * whenever `server.https` is enabled — carries it in the `:authority` + * pseudo-header instead. `createHeaders()` skips pseudo-headers, so `Host` is + * absent from the parsed headers under HTTP/2 and the authority has to be read + * from `req.headers` directly. + * + * Returns `undefined` if neither is present, so callers can apply their own + * fallback. + */ +export function getRequestHost(req: { + headers: http.IncomingHttpHeaders; +}): string | undefined { + const raw = req.headers.host ?? req.headers[":authority"]; + const value = Array.isArray(raw) ? raw[0] : raw; + return value?.trim() || undefined; +} + /** * Parses the `X-Forwarded-Proto` header from an incoming Node.js request. *