Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/vite-plugin-http2-authority-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@cloudflare/vite-plugin": patch
---

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.

The port is now preserved in both, so those redirects come back to the dev
server.
51 changes: 51 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createRequestHandler,
getForwardedProto,
getOutputDirectory,
getRequestAuthority,
} from "../utils";
import type { AddressInfo } from "node:net";

Expand Down Expand Up @@ -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();
Expand Down
27 changes: 25 additions & 2 deletions packages/vite-plugin-cloudflare/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand Down
Loading