From c5632ba37de4e39606f80d80876a26f6a1493a33 Mon Sep 17 00:00:00 2001 From: Amit Vijapur Date: Mon, 17 Aug 2026 18:46:19 +0800 Subject: [PATCH 1/3] [vite-plugin] Preserve the port on requests that arrive over HTTP/2 Over HTTPS the dev server could lose the port from `request.url` and `X-Forwarded-Host`, so a Worker saw `https://localhost` where the browser had asked for `https://localhost:5173`. Auth libraries that rebuild redirect URLs from the request then redirected to the wrong origin and could loop. Plain HTTP was unaffected. Browsers usually negotiate HTTP/2 over HTTPS, and HTTP/2 carries the authority in the `:authority` pseudo-header rather than in `Host`. `createHeaders` drops pseudo-headers when building the Fetch `Request`, so no `Host` was found and the host fell back to a bare `localhost`. `toMiniflareRequest` was gated on the same missing header, so `X-Forwarded-Host` was not set at all. Read the authority from `:authority` when `Host` is absent, and fall back to the resolved request URL when forwarding the host. `Host` is still preferred whenever present, so HTTP/1.1 requests are unchanged. This completes the host and protocol forwarding work in #8706, #13920. Closes #14931 --- .../vite-plugin-http2-authority-port.md | 11 ++++ .../src/__tests__/utils.spec.ts | 51 +++++++++++++++++++ packages/vite-plugin-cloudflare/src/utils.ts | 27 +++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 .changeset/vite-plugin-http2-authority-port.md diff --git a/.changeset/vite-plugin-http2-authority-port.md b/.changeset/vite-plugin-http2-authority-port.md new file mode 100644 index 00000000000..99943d229c7 --- /dev/null +++ b/.changeset/vite-plugin-http2-authority-port.md @@ -0,0 +1,11 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Preserve the port when a request arrives over HTTP/2 + +Serving the dev server over HTTPS meant `request.url` and `X-Forwarded-Host` could lose the port, so a Worker saw `https://localhost` where the browser had asked for `https://localhost:5173`. Auth libraries that rebuild redirect URLs from the request — Clerk's handshake, for example — then redirected to the wrong origin and could loop. Plain HTTP was unaffected. + +Browsers usually negotiate HTTP/2 over HTTPS, and HTTP/2 carries the authority in the `:authority` pseudo-header rather than in `Host`. Pseudo-headers are dropped when the incoming request is converted to a Fetch `Request`, so no `Host` was found and the host fell back to a bare `localhost`. `X-Forwarded-Host` was skipped entirely for the same reason. + +The authority is now read from `:authority` when `Host` is absent, and `X-Forwarded-Host` falls back to the resolved request URL. HTTP/1.1 requests are unchanged, since `Host` is still preferred whenever it is present. diff --git a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts index cfe6729887e..87b368af81d 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts @@ -7,6 +7,7 @@ import { createRequestHandler, getForwardedProto, getOutputDirectory, + getRequestAuthority, } from "../utils"; import type { AddressInfo } from "node:net"; @@ -48,6 +49,56 @@ describe("getOutputDirectory", () => { }); }); +describe("getRequestAuthority", () => { + test("returns undefined when neither header is present", ({ expect }) => { + expect(getRequestAuthority({ headers: {} })).toBeUndefined(); + }); + + test("returns the `Host` header when present", ({ expect }) => { + expect(getRequestAuthority({ headers: { host: "localhost:5173" } })).toBe( + "localhost:5173" + ); + }); + + test("falls back to `:authority` when `Host` is absent", ({ expect }) => { + // HTTP/2 carries the authority here, and the port must survive. + expect( + getRequestAuthority({ headers: { ":authority": "localhost:5173" } }) + ).toBe("localhost:5173"); + }); + + test("prefers `Host` when both are present", ({ expect }) => { + expect( + getRequestAuthority({ + headers: { host: "localhost:5173", ":authority": "example.com" }, + }) + ).toBe("localhost:5173"); + }); + + test("preserves a non-default port from `:authority`", ({ expect }) => { + const authority = getRequestAuthority({ + headers: { ":authority": "192.168.1.10:5173" }, + }); + expect(authority).toBe("192.168.1.10:5173"); + expect(new URL("/", `https://${authority}`).origin).toBe( + "https://192.168.1.10:5173" + ); + }); + + test("returns the first value when the header is an array", ({ expect }) => { + expect( + getRequestAuthority({ + headers: { ":authority": ["a.example:5173", "b.example"] }, + }) + ).toBe("a.example:5173"); + }); + + test("treats an empty or whitespace-only value as absent", ({ expect }) => { + expect(getRequestAuthority({ headers: { host: "" } })).toBeUndefined(); + expect(getRequestAuthority({ headers: { host: " " } })).toBeUndefined(); + }); +}); + describe("getForwardedProto", () => { test("returns undefined when the header is missing", ({ expect }) => { expect(getForwardedProto({ headers: {} })).toBeUndefined(); diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index f30ca3dc063..e5a0c974b1d 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 ?? getRequestAuthority(req) ?? "localhost"; const url = new URL(req.url ?? "/", `${protocol}//${host}`); const init: RequestInit & { duplex?: "half" } = { method, @@ -213,7 +213,10 @@ function createCancellableRequestBody( } function toMiniflareRequest(request: Request): MiniflareRequest { - const host = request.headers.get("Host"); + // Falls back to the URL, which carries the authority resolved in + // `createRequestForIncomingMessage`: over HTTP/2 there is no `Host` header + // to read, and without this the forwarded host would be dropped entirely. + const host = request.headers.get("Host") ?? new URL(request.url).host; const xForwardedHost = request.headers.get("X-Forwarded-Host"); if (host && !xForwardedHost) { @@ -243,6 +246,26 @@ function toMiniflareRequest(request: Request): MiniflareRequest { export const isRolldown = "rolldownVersion" in vite; +/** + * Returns the authority — host and, when present, port — of an incoming + * Node.js request. + * + * HTTP/2 clients carry the authority in the `:authority` pseudo-header rather + * than `Host`, and pseudo-headers are dropped when the Fetch `Headers` are + * built, so `:authority` has to be read from the Node request directly. + * Without it a request over HTTP/2 falls back to a bare `localhost` and loses + * its port. Returns `undefined` if neither is present or the value is empty. + */ +export function getRequestAuthority(req: { + headers: http.IncomingHttpHeaders; +}): string | undefined { + // `Host` is checked first so HTTP/1.1 behaviour is unchanged. + const raw = req.headers["host"] ?? req.headers[":authority"]; + const value = Array.isArray(raw) ? raw[0] : raw; + const authority = value?.trim(); + return authority ? authority : undefined; +} + /** * Parses the `X-Forwarded-Proto` header from an incoming Node.js request. * From 8c737f90a3791a841ce5c7153882905f33d1db39 Mon Sep 17 00:00:00 2001 From: Amit Vijapur Date: Tue, 18 Aug 2026 22:57:44 +0800 Subject: [PATCH 2/3] Describe the changeset in user-facing terms REVIEW.md requires changesets to target users rather than maintainers, so the request-conversion mechanics are dropped in favour of the effect a user sees. --- .changeset/vite-plugin-http2-authority-port.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.changeset/vite-plugin-http2-authority-port.md b/.changeset/vite-plugin-http2-authority-port.md index 99943d229c7..5d91b25b53b 100644 --- a/.changeset/vite-plugin-http2-authority-port.md +++ b/.changeset/vite-plugin-http2-authority-port.md @@ -2,10 +2,14 @@ "@cloudflare/vite-plugin": patch --- -Preserve the port when a request arrives over HTTP/2 +Preserve the port when the dev server is accessed over HTTPS -Serving the dev server over HTTPS meant `request.url` and `X-Forwarded-Host` could lose the port, so a Worker saw `https://localhost` where the browser had asked for `https://localhost:5173`. Auth libraries that rebuild redirect URLs from the request — Clerk's handshake, for example — then redirected to the wrong origin and could loop. Plain HTTP was unaffected. +A Worker served through `vite dev` over HTTPS could see `https://localhost` +where the browser had asked for `https://localhost:5173`. The port was dropped +from both `request.url` and the `X-Forwarded-Host` header, so auth libraries +that rebuild redirect URLs from the incoming request — Clerk's handshake, for +example — sent users to that portless origin and could loop. Serving over plain +HTTP was unaffected. -Browsers usually negotiate HTTP/2 over HTTPS, and HTTP/2 carries the authority in the `:authority` pseudo-header rather than in `Host`. Pseudo-headers are dropped when the incoming request is converted to a Fetch `Request`, so no `Host` was found and the host fell back to a bare `localhost`. `X-Forwarded-Host` was skipped entirely for the same reason. - -The authority is now read from `:authority` when `Host` is absent, and `X-Forwarded-Host` falls back to the resolved request URL. HTTP/1.1 requests are unchanged, since `Host` is still preferred whenever it is present. +The port is now preserved in both, so those redirects come back to the dev +server. From e70475b7713bb211e97f102f32275268a4c8eee6 Mon Sep 17 00:00:00 2001 From: Amit Vijapur Date: Tue, 18 Aug 2026 23:12:44 +0800 Subject: [PATCH 3/3] Unwrap the changeset paragraphs to satisfy check:format --- .changeset/vite-plugin-http2-authority-port.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.changeset/vite-plugin-http2-authority-port.md b/.changeset/vite-plugin-http2-authority-port.md index 5d91b25b53b..eb409f590a6 100644 --- a/.changeset/vite-plugin-http2-authority-port.md +++ b/.changeset/vite-plugin-http2-authority-port.md @@ -4,12 +4,6 @@ Preserve the port when the dev server is accessed over HTTPS -A Worker served through `vite dev` over HTTPS could see `https://localhost` -where the browser had asked for `https://localhost:5173`. The port was dropped -from both `request.url` and the `X-Forwarded-Host` header, so auth libraries -that rebuild redirect URLs from the incoming request — Clerk's handshake, for -example — sent users to that portless origin and could loop. Serving over plain -HTTP was unaffected. +A Worker served through `vite dev` over HTTPS could see `https://localhost` where the browser had asked for `https://localhost:5173`. The port was dropped from both `request.url` and the `X-Forwarded-Host` header, so auth libraries that rebuild redirect URLs from the incoming request — Clerk's handshake, for example — sent users to that portless origin and could loop. Serving over plain HTTP was unaffected. -The port is now preserved in both, so those redirects come back to the dev -server. +The port is now preserved in both, so those redirects come back to the dev server.