Skip to content

Commit c6467d1

Browse files
committed
fix: support HMR on a separate port
1 parent 390f797 commit c6467d1

7 files changed

Lines changed: 149 additions & 5 deletions

File tree

‎.changeset/independent-origin-authorization.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@
55
Add `allowedOrigins` to authorize cross-origin HTTP requests, HMR connections, and WebSocket proxies independently of `allowedHosts` and `client.webSocketURL`. Host configuration no longer disables origin protection, including when `allowedHosts` is `"all"`.
66

77
Cross-origin frontends must now list their full HTTP(S) origin in `allowedOrigins`. Origins include the protocol and port; loopback hostnames are no longer interchangeable. Use CORS (`crossorigin="anonymous"` on script and image elements) so the server can identify the authorized page. The server supplies CORS headers for authorized origins, while Fetch Metadata and resource-policy checks continue to protect requests without `Origin`. Configuring `headers` alone no longer grants cross-origin access. `allowedOrigins: "all"` explicitly opts out of origin checks without disabling host validation.
8+
9+
Set `output.crossOriginLoading: "anonymous"` for webpack-generated lazy chunks and hot-update scripts loaded across origins, and ensure `output.publicPath` resolves to the dev server. The initial script's `crossorigin` attribute does not configure those generated scripts.
10+
11+
HMR on a separate port also accepts the origin of this dev server's HTTP(S) listener on the same hostname, using the actual bound port even with `port: 0`. This exception applies only to HMR; reverse proxies that change the public origin must still configure `allowedOrigins`.

‎examples/allowed-origins/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ authorize another origin.
77

88
```js
99
export default {
10+
output: {
11+
crossOriginLoading: "anonymous",
12+
},
1013
devServer: {
1114
allowedHosts: ["localhost"],
1215
allowedOrigins: ["http://localhost:3000"],
@@ -53,6 +56,12 @@ The CLI accepts repeated flags:
5356
npx webpack serve --allowed-origins http://localhost:3000 --allowed-origins https://app.example.com
5457
```
5558

59+
When HMR listens on a separate port, it also accepts the page served by this
60+
dev server's HTTP(S) listener on the same hostname. This uses the actual bound
61+
port, including with `port: 0`. It applies only to HMR connections; HTTP resources,
62+
WebSocket proxies, and state-changing endpoints keep their existing origin rules.
63+
Reverse proxies that change the public origin still need `allowedOrigins`.
64+
5665
## Migrating cross-origin setups
5766

5867
Previously, an entry in `allowedHosts` or a custom `client.webSocketURL` could
@@ -71,6 +80,11 @@ the browser sends `Origin` and applies CORS:
7180
<script src="http://localhost:8080/main.js" crossorigin="anonymous"></script>
7281
```
7382

83+
Webpack-generated lazy chunks and hot-update scripts also need
84+
`output.crossOriginLoading: "anonymous"` so that the browser sends `Origin`.
85+
Adding `crossorigin` to the initial script does not configure these generated
86+
scripts. Their `output.publicPath` must also resolve to the dev server.
87+
7488
A cross-origin `no-cors` request without `Origin` cannot be matched to an allowed
7589
page, even if it sends a referrer header. Fetch Metadata checks and
7690
`Cross-Origin-Resource-Policy: same-origin` continue to protect those requests.

‎examples/allowed-origins/webpack.config.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { setup } from "../util.js";
33
export default setup(
44
{
55
entry: "./app.js",
6+
output: {
7+
crossOriginLoading: "anonymous",
8+
},
69
devServer: {
710
allowedHosts: ["localhost"],
811
allowedOrigins: ["http://localhost:3000"],

‎lib/Server.js‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2755,7 +2755,7 @@ class Server {
27552755
if (
27562756
!headers ||
27572757
!this.isValidHost(headers, "host") ||
2758-
(!isSameOriginStream && !this.isOriginAllowed(headers))
2758+
(!isSameOriginStream && !this.#isHotOriginAllowed(headers))
27592759
) {
27602760
// Said to the client before it is closed, so the reason reaches the
27612761
// page rather than only this server's log — a refusal that closes
@@ -3480,6 +3480,40 @@ class Server {
34803480
);
34813481
}
34823482

3483+
/**
3484+
* Authorize HMR from a page served by this server when the hot endpoint
3485+
* listens on a separate port, in addition to explicitly allowed origins.
3486+
* @param {{ [key: string]: string | undefined }} headers connection headers
3487+
* @returns {boolean} true when the page may connect to HMR
3488+
*/
3489+
#isHotOriginAllowed(headers) {
3490+
if (this.isOriginAllowed(headers)) {
3491+
return true;
3492+
}
3493+
3494+
const origin = this.#parseOrigin(headers.origin);
3495+
const host = headers[":authority"] || headers.host;
3496+
const address = this.server?.address();
3497+
3498+
if (origin === null || !host || !address || typeof address === "string") {
3499+
return false;
3500+
}
3501+
3502+
const protocol = this.isTlsServer ? "https" : "http";
3503+
const hostOrigin = this.#parseOrigin(`${protocol}://${host}`);
3504+
3505+
if (hostOrigin === null) {
3506+
return false;
3507+
}
3508+
3509+
// Keep the request hostname and the HTTP listener's protocol. Its bound
3510+
// port also handles port: 0; proxy origins still require allowedOrigins.
3511+
const httpOrigin = new URL(hostOrigin);
3512+
httpOrigin.port = String(address.port);
3513+
3514+
return origin === httpOrigin.origin;
3515+
}
3516+
34833517
/**
34843518
* @param {string | undefined} value Origin header value
34853519
* @returns {string | null} serialized HTTP(S) origin, or null if invalid

‎test/e2e/allowed-origins.test.js‎

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ const [port, pagePort] = portsMap["allowed-origins"];
1313
const serverOrigin = `http://localhost:${port}`;
1414
const trustedOrigin = `http://127.0.0.1:${pagePort}`;
1515

16-
async function connect(origin, headers = {}) {
16+
async function connect(origin, headers = {}, socketPort = port) {
1717
return new Promise((resolve, reject) => {
18-
const client = new WebSocket(`ws://localhost:${port}/ws`, {
18+
const client = new WebSocket(`ws://localhost:${socketPort}/ws`, {
1919
origin,
2020
headers,
2121
});
@@ -243,3 +243,83 @@ describe("explicit allowedOrigins", () => {
243243
}
244244
});
245245
});
246+
247+
for (const listenPort of [port, `${port}`, "auto", 0]) {
248+
describe(`HMR on a separate port with HTTP port ${JSON.stringify(listenPort)}`, () => {
249+
let server;
250+
let httpOrigin;
251+
252+
before(async () => {
253+
server = new Server(
254+
{
255+
host: "localhost",
256+
port: listenPort,
257+
webSocketServer: {
258+
type: "ws",
259+
options: { host: "localhost", port: pagePort },
260+
},
261+
},
262+
webpack(config),
263+
);
264+
await server.start();
265+
await new Promise((resolve) => {
266+
server.middleware.waitUntilValid(resolve);
267+
});
268+
httpOrigin = `http://localhost:${server.server.address().port}`;
269+
});
270+
271+
after(async () => {
272+
await server.stop();
273+
});
274+
275+
it("should allow the bound HTTP origin without allowedOrigins", async () => {
276+
expect((await connect(httpOrigin, {}, pagePort)).action).toBe("sync");
277+
});
278+
279+
it("should reject other hosts, protocols, and ports", async () => {
280+
for (const origin of [
281+
httpOrigin.replace("localhost", "127.0.0.1"),
282+
httpOrigin.replace("http:", "https:"),
283+
"http://localhost:1",
284+
"http://localhost:0",
285+
"http://192.168.1.2",
286+
undefined,
287+
]) {
288+
expect((await connect(origin, {}, pagePort)).action).toBe("error");
289+
}
290+
});
291+
292+
it("should still validate the HMR request Host", async () => {
293+
expect(
294+
(
295+
await connect(
296+
httpOrigin.replace("localhost", "evil.example.com"),
297+
{ host: `evil.example.com:${pagePort}` },
298+
pagePort,
299+
)
300+
).action,
301+
).toBe("error");
302+
});
303+
304+
it("should keep HTTP and state-changing endpoints strict about ports", async () => {
305+
for (const endpoint of [
306+
"/main.js",
307+
"/webpack-dev-server/invalidate",
308+
"/webpack-dev-server/open-editor",
309+
]) {
310+
await request(httpOrigin)
311+
.get(endpoint)
312+
.set("Host", `localhost:${pagePort}`)
313+
.set("Origin", httpOrigin)
314+
.expect(403);
315+
}
316+
317+
expect(
318+
server.isSameOrigin({
319+
host: `localhost:${pagePort}`,
320+
origin: httpOrigin,
321+
}),
322+
).toBe(false);
323+
});
324+
});
325+
}

‎test/e2e/proxy-web-socket-origin.test.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe("proxied WebSocket upgrade origin validation", () => {
8484
fs.rmSync(tmp, { recursive: true, force: true });
8585
});
8686

87-
function upgrade(origin) {
87+
function upgrade(origin, headers = {}) {
8888
return new Promise((resolve) => {
8989
const req = http.request({
9090
host: "localhost",
@@ -97,6 +97,7 @@ describe("proxied WebSocket upgrade origin validation", () => {
9797
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
9898
"Sec-WebSocket-Version": "13",
9999
Origin: origin,
100+
...headers,
100101
},
101102
});
102103

@@ -135,6 +136,15 @@ describe("proxied WebSocket upgrade origin validation", () => {
135136
expect(backendConnections).toBe(1);
136137
});
137138

139+
it("should not apply the separate HMR port exception to a proxy", async () => {
140+
const result = await upgrade(`http://localhost:${devServerPort}`, {
141+
Host: `localhost:${backendPort}`,
142+
});
143+
144+
expect(result.status).toBe(403);
145+
expect(backendConnections).toBe(0);
146+
});
147+
138148
for (const options of [
139149
{ allowedHosts: ["localhost"] },
140150
{ allowedHosts: "all" },

‎test/e2e/web-socket-server-url.test.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -826,7 +826,6 @@ describe("web socket server URL", () => {
826826
it(`should work with "client.webSocketURL.port" and "webSocketServer.options.port" options as string ("${webSocketServer}")`, async (t) => {
827827
const compiler = webpack(config);
828828
const devServerOptions = {
829-
allowedOrigins: [`http://127.0.0.1:${port1}`],
830829
webSocketServer: {
831830
type: webSocketServer,
832831
options: {

0 commit comments

Comments
 (0)