diff --git a/lib/Server.js b/lib/Server.js index 9f78cb2042..9cf304e8c3 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3343,10 +3343,20 @@ class Server { return true; } + const loopbacks = new Set(["localhost", "127.0.0.1", "::1"]); + + // url.parse cannot handle bare IPv6 like "::1", need to bracket it first + const hostForParsing = + loopbacks.has(hostHeader) && hostHeader.includes(":") + ? `[${hostHeader}]` + : hostHeader; + // eslint-disable-next-line n/no-deprecated-api const host = url.parse( // if hostHeader doesn't have scheme, add // for parsing. - /^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`, + /^(.+:)?\/\//.test(hostForParsing) + ? hostForParsing + : `//${hostForParsing}`, false, true, ).hostname; @@ -3359,6 +3369,12 @@ class Server { return true; } + // Treat all loopback aliases as equivalent, localhost may resolve to + // 127.0.0.1 or ::1 depending on the OS, causing a false mismatch. + if (loopbacks.has(origin) && loopbacks.has(host)) { + return true; + } + return origin === host; } diff --git a/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 b/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 index edd5d77571..41bada9913 100644 --- a/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`allowed hosts check host headers should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust): console messages 1`] = `[]`; + +exports[`allowed hosts check host headers should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust): page errors 1`] = `[]`; + +exports[`allowed hosts check host headers should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust): response status 1`] = `200`; + exports[`allowed hosts check host headers should allow hosts in allowedHosts: console messages 1`] = `[]`; exports[`allowed hosts check host headers should allow hosts in allowedHosts: page errors 1`] = `[]`; @@ -12,6 +18,24 @@ exports[`allowed hosts check host headers should allow hosts that pass a wildcar exports[`allowed hosts check host headers should allow hosts that pass a wildcard in allowedHosts: response status 1`] = `200`; +exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch): console messages 1`] = `[]`; + +exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch): page errors 1`] = `[]`; + +exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch): response status 1`] = `200`; + +exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch): console messages 1`] = `[]`; + +exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch): page errors 1`] = `[]`; + +exports[`allowed hosts check host headers should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch): response status 1`] = `200`; + +exports[`allowed hosts check host headers should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch): console messages 1`] = `[]`; + +exports[`allowed hosts check host headers should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch): page errors 1`] = `[]`; + +exports[`allowed hosts check host headers should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch): response status 1`] = `200`; + exports[`allowed hosts check host headers should always allow \`localhost\` if options.allowedHosts is auto: console messages 1`] = `[]`; exports[`allowed hosts check host headers should always allow \`localhost\` if options.allowedHosts is auto: page errors 1`] = `[]`; diff --git a/test/e2e/allowed-hosts.test.js b/test/e2e/allowed-hosts.test.js index 163a24f265..ba1cadf726 100644 --- a/test/e2e/allowed-hosts.test.js +++ b/test/e2e/allowed-hosts.test.js @@ -1874,5 +1874,184 @@ describe("allowed hosts", () => { expect(pageErrors).toMatchSnapshot("page errors"); }); + + it("should allow websocket connection when host is 'localhost' but resolves to '127.0.0.1' (loopback alias mismatch)", async () => { + const options = { + allowedHosts: "auto", + host: "localhost", + port: port1, + }; + + server = new Server(options, compiler); + + await server.start(); + + ({ page, browser } = await runBrowser()); + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + // Simulate: browser opens from localhost, but OS resolved + // 'localhost' to '127.0.0.1' so host header is the IP + const headersLocalhostOriginIPv4Host = { + host: "127.0.0.1", + origin: "http://localhost", + }; + + if (!server.isSameOrigin(headersLocalhostOriginIPv4Host)) { + throw new Error( + "isSameOrigin should treat localhost and 127.0.0.1 as equivalent loopback addresses", + ); + } + + const response = await page.goto(`http://localhost:${port1}/main.js`, { + waitUntil: "networkidle0", + }); + + expect(response.status()).toMatchSnapshot("response status"); + expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( + "console messages", + ); + expect(pageErrors).toMatchSnapshot("page errors"); + }); + + it("should allow websocket connection when host is 'localhost' but resolves to '::1' (loopback alias mismatch)", async () => { + const options = { + allowedHosts: "auto", + host: "localhost", + port: port1, + }; + + server = new Server(options, compiler); + + await server.start(); + + ({ page, browser } = await runBrowser()); + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + // Simulate: browser opens from localhost, but OS resolved + // 'localhost' to '::1' (IPv6) so host header is the IPv6 address + const headersLocalhostOriginIPv6Host = { + host: "::1", + origin: "http://localhost", + }; + + if (!server.isSameOrigin(headersLocalhostOriginIPv6Host)) { + throw new Error( + "isSameOrigin should treat localhost and ::1 as equivalent loopback addresses", + ); + } + + const response = await page.goto(`http://localhost:${port1}/main.js`, { + waitUntil: "networkidle0", + }); + + expect(response.status()).toMatchSnapshot("response status"); + expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( + "console messages", + ); + expect(pageErrors).toMatchSnapshot("page errors"); + }); + + it("should allow websocket connection when origin is '127.0.0.1' but host is 'localhost' (reverse loopback alias mismatch)", async () => { + const options = { + allowedHosts: "auto", + host: "127.0.0.1", + port: port1, + }; + + server = new Server(options, compiler); + + await server.start(); + + ({ page, browser } = await runBrowser()); + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + // Reverse of above: server bound to 127.0.0.1, but browser + // sent origin header using 'localhost' name + const headersIPv4OriginLocalhostHost = { + host: "localhost", + origin: "http://127.0.0.1", + }; + + if (!server.isSameOrigin(headersIPv4OriginLocalhostHost)) { + throw new Error( + "isSameOrigin should treat 127.0.0.1 and localhost as equivalent loopback addresses", + ); + } + + const response = await page.goto(`http://127.0.0.1:${port1}/main.js`, { + waitUntil: "networkidle0", + }); + + expect(response.status()).toMatchSnapshot("response status"); + expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( + "console messages", + ); + expect(pageErrors).toMatchSnapshot("page errors"); + }); + + it("should NOT allow websocket connection when origin is a non-loopback address mismatching host (loopback fix must not widen trust)", async () => { + const options = { + allowedHosts: "auto", + host: "localhost", + port: port1, + }; + + server = new Server(options, compiler); + + await server.start(); + + ({ page, browser } = await runBrowser()); + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + // A real external origin must never pass as loopback equivalent. + const headersExternalOrigin = { + host: "localhost", + origin: "http://evil.example.com", + }; + + if (server.isSameOrigin(headersExternalOrigin)) { + throw new Error( + "isSameOrigin must NOT allow external origins to match loopback host", + ); + } + + const response = await page.goto(`http://localhost:${port1}/main.js`, { + waitUntil: "networkidle0", + }); + + expect(response.status()).toMatchSnapshot("response status"); + expect(consoleMessages.map((message) => message.text())).toMatchSnapshot( + "console messages", + ); + expect(pageErrors).toMatchSnapshot("page errors"); + }); }); });