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
5 changes: 5 additions & 0 deletions .changeset/fix-proxy-hijacking-hmr-websocket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-server": patch
---

Prevent user-defined `ws: true` proxies from intercepting the dev server's own HMR WebSocket upgrade.
28 changes: 23 additions & 5 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -1756,13 +1756,31 @@ class Server {
/** @type {RequestHandler[]} */
(this.webSocketProxies);

const hmrPath =
this.options.webSocketServer &&
/** @type {WebSocketServerConfiguration} */
(this.options.webSocketServer).options &&
/** @type {NonNullable<WebSocketServerConfiguration["options"]>} */
(
/** @type {WebSocketServerConfiguration} */
(this.options.webSocketServer).options
).path;

for (const webSocketProxy of webSocketProxies) {
/** @type {S} */
(this.server).on(
"upgrade",
const proxyUpgrade =
/** @type {RequestHandler & { upgrade: NonNullable<RequestHandler["upgrade"]> }} */
(webSocketProxy).upgrade,
);
(webSocketProxy).upgrade;

/** @type {S} */
(this.server).on("upgrade", (req, socket, head) => {
if (hmrPath && req.url) {
const { pathname } = new URL(req.url, "http://0.0.0.0");
if (pathname === hmrPath) {
return;
}
}
proxyUpgrade(req, socket, head);
});
}
}

Expand Down
8 changes: 4 additions & 4 deletions test/e2e/__snapshots__/port.test.js.snap.webpack5
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,27 @@ exports[`port > should work using "0" port 2`] = `
[]
`;

exports[`port > should work using "8159" port 1`] = `
exports[`port > should work using "8160" port 1`] = `
[
"[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.",
"[HMR] Waiting for update signal from WDS...",
"Hey.",
]
`;

exports[`port > should work using "8159" port 2`] = `
exports[`port > should work using "8160" port 2`] = `
[]
`;

exports[`port > should work using "8159" port 3`] = `
exports[`port > should work using "8160" port 3`] = `
[
"[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.",
"[HMR] Waiting for update signal from WDS...",
"Hey.",
]
`;

exports[`port > should work using "8159" port 4`] = `
exports[`port > should work using "8160" port 4`] = `
[]
`;

Expand Down
2 changes: 1 addition & 1 deletion test/ports-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const listOfTests = {
"on-listening-option": 1,
"open-option": 1,
"port-option": 1,
"proxy-option": 4,
"proxy-option": 5,
server: 1,
"setup-exit-signals-option": 1,
"static-directory-option": 1,
Expand Down
4 changes: 4 additions & 0 deletions test/server/__snapshots__/proxy-option.test.js.snap.webpack5
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
exports[`proxy option > should not log proxy errors for the dev-server HMR upgrade > does not surface any [HPM] error when the HMR client connects 1`] = `
""
`;

exports[`proxy option > should work and respect \`logger\` option > target > respects a proxy option when a request path is matched 1`] = `
"[HPM] Error occurred while proxying request %s to %s [%s] (%s)"
`;
Expand Down
200 changes: 199 additions & 1 deletion test/server/proxy-option.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import http from "node:http";
import path from "node:path";
import { after, before, beforeEach, describe, it } from "node:test";
import { fileURLToPath } from "node:url";
Expand All @@ -13,7 +14,7 @@ import portsMap from "../ports-map.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const [port1, port2, port3, port4] = portsMap["proxy-option"];
const [port1, port2, port3, port4, port5] = portsMap["proxy-option"];

const staticDirectory = path.resolve(__dirname, "../fixtures/proxy-config");

Expand Down Expand Up @@ -609,6 +610,203 @@ describe("proxy option", () => {
}
});

describe("should not silently proxy dev-server HMR websocket to a permissive backend", () => {
let server;
let backend;
let backendWss;
let backendUpgradeCount;

const BACKEND_MESSAGE_TYPE = "backend-message";

before(async () => {
backendUpgradeCount = 0;

backend = http.createServer();
backendWss = new WebSocketServer({ server: backend });
backendWss.on("connection", (connection) => {
backendUpgradeCount += 1;
connection.send(JSON.stringify({ type: BACKEND_MESSAGE_TYPE }));
});
await new Promise((resolve) => {
backend.listen(port5, resolve);
});

const compiler = webpack(config);

server = new Server(
{
hot: true,
allowedHosts: "all",
webSocketServer: "ws",
proxy: [
{
context: "/",
target: `http://localhost:${port5}`,
ws: true,
},
],
port: port3,
},
compiler,
);

await server.start();
});

after(async () => {
for (const client of backendWss.clients) {
client.terminate();
}
backendWss.close();
// Force-drop any lingering proxy-opened sockets so backend.close() does
// not hang when the fix is missing and the proxy is mid-upgrade.
backend.closeAllConnections();
await server.stop();
await new Promise((resolve) => {
backend.close(resolve);
});
});

it("delivers the HMR control messages and never reaches the proxy target", async () => {
const messages = [];

const ws = new WebSocket(`ws://localhost:${port3}/ws`);

await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`Timed out waiting for HMR message. Got: ${JSON.stringify(messages)}`,
),
);
}, 3000);

ws.on("message", (raw) => {
const parsed = JSON.parse(raw.toString());
messages.push(parsed);
if (parsed.type === "hot") {
clearTimeout(timer);
resolve();
}
});

ws.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});

ws.close();

// Let the proxy finish its async forwarding so the assertion below sees
// the upgrade attempt deterministically (without this, the proxy may
// reach the backend only after the test exits).
await new Promise((resolve) => {
setTimeout(resolve, 300);
});

expect(messages.some((m) => m.type === "hot")).toBe(true);
expect(messages.some((m) => m.type === BACKEND_MESSAGE_TYPE)).toBe(false);
expect(backendUpgradeCount).toBe(0);
});
});

describe("should not log proxy errors for the dev-server HMR upgrade", () => {
let server;
let backend;
let stderrSpy;

before(async () => {
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);

backend = http.createServer();
backend.on("upgrade", (req, socket) => {
socket.destroy();
});
await new Promise((resolve) => {
backend.listen(port5, resolve);
});

const compiler = webpack(config);

server = new Server(
{
hot: true,
allowedHosts: "all",
webSocketServer: "ws",
proxy: [
{
context: "/",
target: `http://localhost:${port5}`,
ws: true,
},
],
port: port3,
},
compiler,
);

await server.start();
});

after(async () => {
stderrSpy.mockRestore();
backend.closeAllConnections();
await server.stop();
await new Promise((resolve) => {
backend.close(resolve);
});
});

it("does not surface any [HPM] error when the HMR client connects", async (t) => {
const messages = [];

const ws = new WebSocket(`ws://localhost:${port3}/ws`);

await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`Timed out waiting for HMR message. Got: ${JSON.stringify(messages)}`,
),
);
}, 3000);

ws.on("message", (raw) => {
const parsed = JSON.parse(raw.toString());
messages.push(parsed);
if (parsed.type === "hot") {
clearTimeout(timer);
resolve();
}
});

ws.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});

ws.close();

// Give the proxy a moment to (incorrectly) log any error.
await new Promise((resolve) => {
setTimeout(resolve, 200);
});

const hpmLines = stderrSpy.mock.calls
.map((c) => c[0])
.join("")
.split("\n")
.filter((line) => line.includes("[HPM]"))
.map((line) => line.replaceAll(/localhost:\d+/g, "localhost:<port>"))
.join("\n");

t.assert.snapshot(hpmLines);
expect(messages.some((m) => m.type === "hot")).toBe(true);
});
});

describe("should supports http methods", () => {
let server;
let req;
Expand Down
Loading