diff --git a/lib/Server.js b/lib/Server.js index 19631099ab..47c3c58877 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1860,6 +1860,25 @@ class Server { (this.options.webSocketServer).options ).path; + // Normalize a URL path for HMR-path comparison: lowercase, decode + // percent-encoding (so `/%77%73` matches `/ws`), and strip trailing + // slashes. Returns null when decoding fails on malformed percent escapes. + /** + * @param {string} pathToNormalize URL path to normalize for HMR comparison. + * @returns {string | null} Normalized path, or null on decode failure. + */ + const normalizeHmrPath = (pathToNormalize) => { + try { + return decodeURIComponent(pathToNormalize) + .toLowerCase() + .replace(/\/+$/, ""); + } catch { + return null; + } + }; + + const normalizedHmrPath = hmrPath ? normalizeHmrPath(hmrPath) : null; + for (const webSocketProxy of webSocketProxies) { const proxyUpgrade = /** @type {RequestHandler & { upgrade: NonNullable }} */ @@ -1867,9 +1886,24 @@ class Server { /** @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) { + if ( + normalizedHmrPath && + typeof req.url === "string" && + req.url.startsWith("/") + ) { + // Collapse leading multiple-slashes so `//ws` is not parsed as a + // scheme-relative URL (which would yield `pathname === "/"` and + // bypass the HMR-path filter). + const cleanUrl = req.url.replace(/^\/+/, "/"); + let pathname; + try { + ({ pathname } = new URL(cleanUrl, "http://0.0.0.0")); + } catch { + // Malformed URL: fail closed (do not forward to proxy). + return; + } + const normalized = normalizeHmrPath(pathname); + if (normalized !== null && normalized === normalizedHmrPath) { return; } } diff --git a/test/server/proxy-option.test.js b/test/server/proxy-option.test.js index c82e56d13e..4c0f9daf35 100644 --- a/test/server/proxy-option.test.js +++ b/test/server/proxy-option.test.js @@ -859,6 +859,90 @@ describe("proxy option", () => { }); }); + describe("HMR upgrade path matching tolerates URL variants of the configured path", () => { + let server; + let backend; + let backendUpgradeCount; + + beforeAll(async () => { + backendUpgradeCount = 0; + + backend = http.createServer(); + new WebSocketServer({ server: backend }).on("connection", () => { + backendUpgradeCount += 1; + }); + + 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(); + }); + + afterAll(async () => { + backend.closeAllConnections(); + await server.stop(); + await new Promise((resolve) => { + backend.close(resolve); + }); + }); + + // The HMR server's default path is /ws. These variants should all be + // recognized as the HMR socket and not forwarded through the user proxy. + const variants = [ + ["trailing slash", "/ws/"], + ["uppercase", "/WS"], + ["mixed case", "/wS"], + ["percent-encoded path", "/%77%73"], + ["leading double slash", "//ws"], + ]; + + it.each(variants)( + "treats %s (%s) as the HMR upgrade path", + async (_label, path) => { + const before = backendUpgradeCount; + + const ws = new WebSocket(`ws://localhost:${port3}${path}`); + ws.on("error", () => {}); + + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + try { + ws.close(); + } catch { + // ignore close errors on already-failed sockets + } + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + expect(backendUpgradeCount).toBe(before); + }, + ); + }); + describe("should supports http methods", () => { let server; let req;