Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions .changeset/http2-authority-host-resolution.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void>((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<typeof handler>[0],
res as unknown as Parameters<typeof handler>[1],
(error: unknown) => {
res.statusCode = 500;
res.end(error instanceof Error ? error.message : String(error));
}
);
});

return new Promise<void>((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<void>((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}`);
});
});
26 changes: 24 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 ?? getRequestHost(req) ?? "localhost";
const url = new URL(req.url ?? "/", `${protocol}//${host}`);
const init: RequestInit & { duplex?: "half" } = {
method,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand Down
Loading