From 347c7c63994538e3552e373b9d9a182be6918825 Mon Sep 17 00:00:00 2001 From: intrdx Date: Thu, 30 Jul 2026 17:17:11 +0530 Subject: [PATCH 1/2] fix: preserve HTTPS/HTTP2 host port in vite-plugin requests Co-authored-by: Cursor --- .changeset/fix-https-http2-host-port.md | 5 +++ .../src/__tests__/utils.spec.ts | 34 ++++++++++++++++++- packages/vite-plugin-cloudflare/src/utils.ts | 25 +++++++++++--- 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-https-http2-host-port.md diff --git a/.changeset/fix-https-http2-host-port.md b/.changeset/fix-https-http2-host-port.md new file mode 100644 index 00000000000..077aaa12978 --- /dev/null +++ b/.changeset/fix-https-http2-host-port.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Preserve the host and non-default port (e.g. `localhost:5173`) when the Vite dev server runs over HTTPS/HTTP2, so authentication flows such as Clerk no longer redirect-loop to the wrong origin diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index cfe6729887e..a9beab5fea1 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts @@ -107,9 +107,11 @@ describe("createRequestHandler", () => { let httpServer: http.Server; let port: number; let capturedUrls: string[]; + let capturedForwardedHosts: (string | null)[]; beforeEach(async () => { capturedUrls = []; + capturedForwardedHosts = []; }); afterEach(async () => { @@ -118,13 +120,15 @@ describe("createRequestHandler", () => { ); }); - function startServer() { + function startServer(mutateReq?: (req: http.IncomingMessage) => void) { const handler = createRequestHandler(async (request) => { capturedUrls.push(request.url); + capturedForwardedHosts.push(request.headers.get("X-Forwarded-Host")); return new MiniflareResponse("OK"); }); httpServer = http.createServer((req, res) => { + mutateReq?.(req); void handler( req as unknown as Parameters[0], res, @@ -367,4 +371,32 @@ describe("createRequestHandler", () => { socket.destroy(); } }); + + test("preserves non-default port from `:authority` when `Host` is missing", async ({ + expect, + }) => { + // Simulate HTTP/2: mutateReq removes `host` and injects `:authority` so the + // plugin falls back to the pseudo-header for the origin. + await startServer((req) => { + delete req.headers.host; + req.headers[":authority"] = "localhost:5173"; + }); + await fetch(`http://127.0.0.1:${port}/path`); + expect(capturedUrls[0]).toBe("http://localhost:5173/path"); + expect(capturedForwardedHosts[0]).toBe("localhost:5173"); + }); + + test("preserves non-default port from the `Host` header", async ({ + expect, + }) => { + // The Host header is the normal HTTP/1.1 mechanism; override it in mutateReq + // so the plugin uses the value the client would have sent in a real Vite HTTPS + // setup (e.g. localhost:5173 instead of 127.0.0.1:). + await startServer((req) => { + req.headers.host = "localhost:5173"; + }); + await fetch(`http://127.0.0.1:${port}/path`); + expect(capturedUrls[0]).toBe("http://localhost:5173/path"); + expect(capturedForwardedHosts[0]).toBe("localhost:5173"); + }); }); diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index f30ca3dc063..5c34b202bff 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -97,11 +97,26 @@ export function createRequestHandler( // If the header is absent or invalid, `createRequest` falls back to the // connection protocol (`req.socket.encrypted`). const protocol = getForwardedProto(req); - request = createRequestForIncomingMessage( - req, - res, - protocol ? { protocol } : undefined - ); + // Prefer Node host/:authority so HTTPS/HTTP2 keeps non-default ports (e.g. :5173). + // createRequest only applies `options.host` to request.url; Host still comes from + // rawHeaders (and createHeaders skips :authority), so we must set Host ourselves + // for toMiniflareRequest to populate X-Forwarded-Host with the same origin. + const nodeHost = + typeof req.headers.host === "string" ? req.headers.host : undefined; + const authority = + typeof req.headers[":authority"] === "string" + ? req.headers[":authority"] + : undefined; + const host = nodeHost ?? authority; + + request = createRequestForIncomingMessage(req, res, { + ...(protocol ? { protocol } : {}), + ...(host ? { host } : {}), + }); + + if (host) { + request.headers.set("Host", host); + } let response = await handler(toMiniflareRequest(request), req); From 4a2ebced4e882856f0d95128a024d96f8d532fd5 Mon Sep 17 00:00:00 2001 From: intrdx Date: Sun, 6 Sep 2026 21:26:33 +0530 Subject: [PATCH 2/2] fix: resolve HTTP/2 authority via getRequestHost and h2c tests Adopt createRequestForIncomingMessage host resolution and toMiniflareRequest URL-host fallback from #15533, replacing the outer Host force-set. Cover the :authority path with cleartext HTTP/2 tests. Co-authored-by: SHAIK VAHID <38548782+vahidshaik1901@users.noreply.github.com> --- .../src/__tests__/utils.spec.ts | 94 ++++++++++++++----- packages/vite-plugin-cloudflare/src/utils.ts | 51 +++++----- 2 files changed, 98 insertions(+), 47 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index a9beab5fea1..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"; @@ -107,11 +108,9 @@ describe("createRequestHandler", () => { let httpServer: http.Server; let port: number; let capturedUrls: string[]; - let capturedForwardedHosts: (string | null)[]; beforeEach(async () => { capturedUrls = []; - capturedForwardedHosts = []; }); afterEach(async () => { @@ -120,15 +119,13 @@ describe("createRequestHandler", () => { ); }); - function startServer(mutateReq?: (req: http.IncomingMessage) => void) { + function startServer() { const handler = createRequestHandler(async (request) => { capturedUrls.push(request.url); - capturedForwardedHosts.push(request.headers.get("X-Forwarded-Host")); return new MiniflareResponse("OK"); }); httpServer = http.createServer((req, res) => { - mutateReq?.(req); void handler( req as unknown as Parameters[0], res, @@ -371,32 +368,79 @@ describe("createRequestHandler", () => { socket.destroy(); } }); +}); + +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())) + ); + }); - test("preserves non-default port from `:authority` when `Host` is missing", async ({ + 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, }) => { - // Simulate HTTP/2: mutateReq removes `host` and injects `:authority` so the - // plugin falls back to the pseudo-header for the origin. - await startServer((req) => { - delete req.headers.host; - req.headers[":authority"] = "localhost:5173"; - }); - await fetch(`http://127.0.0.1:${port}/path`); - expect(capturedUrls[0]).toBe("http://localhost:5173/path"); - expect(capturedForwardedHosts[0]).toBe("localhost:5173"); + await startH2Server(); + await h2Get("/path"); + expect(capturedUrls[0]).toBe(`http://127.0.0.1:${h2Port}/path`); }); - test("preserves non-default port from the `Host` header", async ({ + test("sets `X-Forwarded-Host` from the `:authority` pseudo-header", async ({ expect, }) => { - // The Host header is the normal HTTP/1.1 mechanism; override it in mutateReq - // so the plugin uses the value the client would have sent in a real Vite HTTPS - // setup (e.g. localhost:5173 instead of 127.0.0.1:). - await startServer((req) => { - req.headers.host = "localhost:5173"; - }); - await fetch(`http://127.0.0.1:${port}/path`); - expect(capturedUrls[0]).toBe("http://localhost:5173/path"); - expect(capturedForwardedHosts[0]).toBe("localhost:5173"); + 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 5c34b202bff..b448706839b 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -97,26 +97,11 @@ export function createRequestHandler( // If the header is absent or invalid, `createRequest` falls back to the // connection protocol (`req.socket.encrypted`). const protocol = getForwardedProto(req); - // Prefer Node host/:authority so HTTPS/HTTP2 keeps non-default ports (e.g. :5173). - // createRequest only applies `options.host` to request.url; Host still comes from - // rawHeaders (and createHeaders skips :authority), so we must set Host ourselves - // for toMiniflareRequest to populate X-Forwarded-Host with the same origin. - const nodeHost = - typeof req.headers.host === "string" ? req.headers.host : undefined; - const authority = - typeof req.headers[":authority"] === "string" - ? req.headers[":authority"] - : undefined; - const host = nodeHost ?? authority; - - request = createRequestForIncomingMessage(req, res, { - ...(protocol ? { protocol } : {}), - ...(host ? { host } : {}), - }); - - if (host) { - request.headers.set("Host", host); - } + request = createRequestForIncomingMessage( + req, + res, + protocol ? { protocol } : undefined + ); let response = await handler(toMiniflareRequest(request), req); @@ -160,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, @@ -228,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) { @@ -258,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. *