diff --git a/lib/Server.js b/lib/Server.js index 79e6fc7910..f62ec8f81b 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -250,6 +250,8 @@ const encodeOverlaySettings = (setting) => ? encodeURIComponent(setting.toString()) : setting; +const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i; + class Server { /** * @param {Configuration | Compiler | MultiCompiler} options @@ -2011,10 +2013,11 @@ class Server { */ (req, res, next) => { if ( - this.checkHeader( + this.isValidHost( /** @type {{ [key: string]: string | undefined }} */ (req.headers), - "host" + "host", + true ) ) { return next(); @@ -2208,6 +2211,40 @@ class Server { this.options.onBeforeSetupMiddleware(this); } + // Register setup cross origin request check for securityAdd commentMore actions + middlewares.push({ + name: "cross-origin-header-check", + /** + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {void} + */ + middleware: (req, res, next) => { + const headers = + /** @type {{ [key: string]: string | undefined }} */ + (req.headers); + + const headerName = headers[":authority"] ? ":authority" : "host"; + + if (this.isValidHost(headers, headerName, false)) { + next(); + return; + } + + if ( + headers["sec-fetch-mode"] === "no-cors" && + headers["sec-fetch-site"] === "cross-site" + ) { + res.statusCode = 403; + res.end("Cross-Origin request blocked"); + return; + } + + next(); + }, + }); + if (typeof this.options.headers !== "undefined") { middlewares.push({ name: "set-headers", @@ -2598,8 +2635,8 @@ class Server { if ( !headers || - !this.checkHeader(headers, "host") || - !this.checkHeader(headers, "origin") + !this.isValidHost(headers, "host", true) || + !this.isValidHost(headers, "origin", false) ) { this.sendMessage([client], "error", "Invalid Host/Origin header"); @@ -3055,75 +3092,93 @@ class Server { * @private * @param {{ [key: string]: string | undefined }} headers * @param {string} headerToCheck + * @param {boolean} validateHost * @returns {boolean} */ - checkHeader(headers, headerToCheck) { - // allow user to opt out of this security check, at their own risk - // by explicitly enabling allowedHosts + isValidHost(headers, headerToCheck, validateHost = true) { if (this.options.allowedHosts === "all") { return true; } // get the Host header and extract hostname // we don't care about port not matching - const hostHeader = headers[headerToCheck]; + const header = headers[headerToCheck]; - if (!hostHeader) { + if (!header) { return false; } - if (/^(file|.+-extension):/i.test(hostHeader)) { + if (DEFAULT_ALLOWED_PROTOCOLS.test(header)) { return true; } // use the node url-parser to retrieve the hostname from the host-header. const hostname = url.parse( - // if hostHeader doesn't have scheme, add // for parsing. - /^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`, + // if header doesn't have scheme, add // for parsing. + /^(.+:)?\/\//.test(header) ? header : `//${header}`, false, true ).hostname; + if (hostname === null) { + return false; + } + + if (this.isHostAllowed(hostname)) { + return true; + } + // always allow requests with explicit IPv4 or IPv6-address. // A note on IPv6 addresses: - // hostHeader will always contain the brackets denoting + // header will always contain the brackets denoting // an IPv6-address in URLs, // these are removed from the hostname in url.parse(), // so we have the pure IPv6-address in hostname. // For convenience, always allow localhost (hostname === 'localhost') // and its subdomains (hostname.endsWith(".localhost")). // allow hostname of listening address (hostname === this.options.host) - const isValidHostname = - (hostname !== null && ipaddr.IPv4.isValid(hostname)) || - (hostname !== null && ipaddr.IPv6.isValid(hostname)) || - hostname === "localhost" || - (hostname !== null && hostname.endsWith(".localhost")) || - hostname === this.options.host; - - if (isValidHostname) { - return true; - } + const isValidHostname = validateHost + ? ipaddr.IPv4.isValid(hostname) || + ipaddr.IPv6.isValid(hostname) || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === this.options.host + : true; + + return isValidHostname; + } + /** + * @private + * @param {string} value + * @returns {boolean} + */ + isHostAllowed(value) { const { allowedHosts } = this.options; + // allow user to opt out of this security check, at their own risk + // by explicitly enabling allowedHosts + if (allowedHosts === "all") { + return true; + } + // always allow localhost host, for convenience - // allow if hostname is in allowedHosts + // allow if value is in allowedHosts if (Array.isArray(allowedHosts) && allowedHosts.length > 0) { - for (let hostIdx = 0; hostIdx < allowedHosts.length; hostIdx++) { - const allowedHost = allowedHosts[hostIdx]; - - if (allowedHost === hostname) { + for (const allowedHost of allowedHosts) { + if (allowedHost === value) { return true; } // support "." as a subdomain wildcard // e.g. ".example.com" will allow "example.com", "www.example.com", "subdomain.example.com", etc - if (allowedHost[0] === ".") { - // "example.com" (hostname === allowedHost.substring(1)) - // "*.example.com" (hostname.endsWith(allowedHost)) + if (allowedHost.startsWith(".")) { + // "example.com" (value === allowedHost.substring(1)) + // "*.example.com" (value.endsWith(allowedHost)) if ( - hostname === allowedHost.substring(1) || - /** @type {string} */ (hostname).endsWith(allowedHost) + value === allowedHost.substring(1) || + /** @type {string} */ + (value).endsWith(allowedHost) ) { return true; } @@ -3135,17 +3190,17 @@ class Server { if ( this.options.client && typeof ( - /** @type {ClientConfiguration} */ (this.options.client).webSocketURL + /** @type {ClientConfiguration} */ + (this.options.client).webSocketURL ) !== "undefined" ) { return ( /** @type {WebSocketURL} */ (/** @type {ClientConfiguration} */ (this.options.client).webSocketURL) - .hostname === hostname + .hostname === value ); } - // disallow return false; } @@ -3166,6 +3221,64 @@ class Server { } } + /** + * @private + * @param {{ [key: string]: string | undefined }} headers + * @returns {boolean} + */ + isSameOrigin(headers) { + if (this.options.allowedHosts === "all") { + return true; + } + + const originHeader = headers.origin; + + if (!originHeader) { + return this.options.allowedHosts === "all"; + } + + if (DEFAULT_ALLOWED_PROTOCOLS.test(originHeader)) { + return true; + } + + const origin = url.parse(originHeader, false, true).hostname; + + if (origin === null) { + return false; + } + + if (this.isHostAllowed(origin)) { + return true; + } + + const hostHeader = headers.host; + + if (!hostHeader) { + return this.options.allowedHosts === "all"; + } + + if (DEFAULT_ALLOWED_PROTOCOLS.test(hostHeader)) { + return true; + } + + const host = url.parse( + // if hostHeader doesn't have scheme, add // for parsing. + /^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`, + false, + true + ).hostname; + + if (host === null) { + return false; + } + + if (this.isHostAllowed(host)) { + return true; + } + + return origin === host; + } + /** * @private * @param {Request} req diff --git a/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 b/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 index 1bd829b500..71fe254c92 100644 --- a/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5 @@ -274,6 +274,32 @@ exports[`allowed hosts should disconnect web client using localhost to web socke exports[`allowed hosts should disconnect web client using localhost to web socket server with the "auto" value ("ws"): page errors 1`] = `Array []`; +exports[`allowed hosts should disconnect web client with origin header containing an IP address with the "auto" value ("sockjs"): console messages 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.", + "[webpack-dev-server] Invalid Host/Origin header", + "[webpack-dev-server] Disconnected!", + "[webpack-dev-server] Trying to reconnect...", +] +`; + +exports[`allowed hosts should disconnect web client with origin header containing an IP address with the "auto" value ("sockjs"): page errors 1`] = `[]`; + +exports[`allowed hosts should disconnect web client with origin header containing an IP address with the "auto" value ("ws"): console messages 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.", + "[webpack-dev-server] Invalid Host/Origin header", + "[webpack-dev-server] Disconnected!", + "[webpack-dev-server] Trying to reconnect...", +] +`; + +exports[`allowed hosts should disconnect web client with origin header containing an IP address with the "auto" value ("ws"): page errors 1`] = `[]`; + exports[`allowed hosts should disconnect web socket client using custom hostname from web socket server with the "auto" value based on the "host" header ("sockjs"): console messages 1`] = ` Array [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", diff --git a/test/e2e/allowed-hosts.test.js b/test/e2e/allowed-hosts.test.js index bc13801102..252eeb0f04 100644 --- a/test/e2e/allowed-hosts.test.js +++ b/test/e2e/allowed-hosts.test.js @@ -1147,6 +1147,86 @@ describe("allowed hosts", () => { await browser.close(); await server.stop(); }); + + it(`should disconnect web client with origin header containing an IP address with the "auto" value ("${webSocketServer}")`, async () => { + const devServerHost = "127.0.0.1"; + const devServerPort = port1; + const proxyHost = devServerHost; + const proxyPort = port2; + + const compiler = webpack(config); + const devServerOptions = { + client: { + webSocketURL: { + port: port2, + }, + }, + webSocketServer, + port: devServerPort, + host: devServerHost, + allowedHosts: "auto", + }; + const server = new Server(devServerOptions, compiler); + + await server.start(); + + function startProxy(callback) { + const app = express(); + + app.use( + "/", + createProxyMiddleware({ + // Emulation + onProxyReqWs: (proxyReq) => { + proxyReq.setHeader("origin", "http://192.168.1.1/"); + }, + target: `http://${devServerHost}:${devServerPort}`, + ws: true, + changeOrigin: true, + logLevel: "warn", + }) + ); + + return app.listen(proxyPort, proxyHost, callback); + } + + const proxy = await new Promise((resolve) => { + const proxyCreated = startProxy(() => { + resolve(proxyCreated); + }); + }); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + const consoleMessages = []; + + page + .on("console", (message) => { + consoleMessages.push(message); + }) + .on("pageerror", (error) => { + pageErrors.push(error); + }); + + await page.goto(`http://${proxyHost}:${proxyPort}/`, { + waitUntil: "networkidle0", + }); + + expect( + consoleMessages.map((message) => message.text()) + ).toMatchSnapshot("console messages"); + expect(pageErrors).toMatchSnapshot("page errors"); + } catch (error) { + throw error; + } finally { + proxy.close(); + + await browser.close(); + await server.stop(); + } + }); } describe("check host headers", () => { @@ -1196,7 +1276,7 @@ describe("allowed hosts", () => { waitUntil: "networkidle0", }); - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't fail"); } @@ -1237,7 +1317,7 @@ describe("allowed hosts", () => { waitUntil: "networkidle0", }); - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't fail"); } @@ -1280,7 +1360,7 @@ describe("allowed hosts", () => { waitUntil: "networkidle0", }); - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't fail"); } @@ -1324,7 +1404,7 @@ describe("allowed hosts", () => { waitUntil: "networkidle0", }); - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't fail"); } @@ -1364,7 +1444,7 @@ describe("allowed hosts", () => { waitUntil: "networkidle0", }); - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't fail"); } @@ -1405,7 +1485,7 @@ describe("allowed hosts", () => { tests.forEach((test) => { const headers = { host: test }; - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't fail"); } }); @@ -1455,7 +1535,7 @@ describe("allowed hosts", () => { tests.forEach((test) => { const headers = { host: test }; - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't fail"); } }); diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index ab95512ba3..8df516d0c9 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -837,7 +837,7 @@ describe("API", () => { tests.forEach((test) => { const headers = { host: test }; - if (!server.checkHeader(headers, "host")) { + if (!server.isValidHost(headers, "host")) { throw new Error("Validation didn't pass"); } }); @@ -886,7 +886,7 @@ describe("API", () => { waitUntil: "networkidle0", }); - if (!server.checkHeader(headers, "origin")) { + if (!server.isValidHost(headers, "origin")) { throw new Error("Validation didn't fail"); } diff --git a/test/e2e/cross-origin-request.test.js b/test/e2e/cross-origin-request.test.js new file mode 100644 index 0000000000..e46857dcaf --- /dev/null +++ b/test/e2e/cross-origin-request.test.js @@ -0,0 +1,224 @@ +"use strict"; + +const webpack = require("webpack"); +const Server = require("../../lib/Server"); +const config = require("../fixtures/client-config/webpack.config"); +const runBrowser = require("../helpers/run-browser"); +const [port1, port2] = require("../ports-map")["cross-origin-request"]; + +describe("cross-origin requests", () => { + const devServerPort = port1; + const htmlServerPort = port2; + const htmlServerHost = "127.0.0.1"; + + it("should return 403 for cross-origin no-cors non-module script tag requests", async () => { + const compiler = webpack(config); + const devServerOptions = { + port: devServerPort, + allowedHosts: "auto", + }; + const server = new Server(devServerOptions, compiler); + + await server.start(); + + // Start a separate server for serving the HTML file + const http = require("http"); + const htmlServer = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + +
+ + + + + `); + }); + htmlServer.listen(htmlServerPort, htmlServerHost); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + + page.on("pageerror", (error) => { + pageErrors.push(error); + }); + + const scriptTagRequest = page.waitForResponse( + `http://localhost:${devServerPort}/main.js` + ); + + await page.goto(`http://${htmlServerHost}:${htmlServerPort}`); + + const response = await scriptTagRequest; + + expect(response.status()).toBe(403); + } catch (error) { + throw error; + } finally { + await browser.close(); + await server.stop(); + htmlServer.close(); + } + }); + + it("should return 200 for cross-origin cors non-module script tag requests", async () => { + const compiler = webpack(config); + const devServerOptions = { + port: devServerPort, + allowedHosts: "auto", + headers: { + "Access-Control-Allow-Origin": "*", + }, + }; + const server = new Server(devServerOptions, compiler); + + await server.start(); + + // Start a separate server for serving the HTML file + const http = require("http"); + const htmlServer = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + + + + + `); + }); + htmlServer.listen(htmlServerPort, htmlServerHost); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + + page.on("pageerror", (error) => { + pageErrors.push(error); + }); + + const scriptTagRequest = page.waitForResponse( + `http://localhost:${devServerPort}/main.js` + ); + + await page.goto(`http://${htmlServerHost}:${htmlServerPort}`); + + const response = await scriptTagRequest; + + expect(response.status()).toBe(200); + } catch (error) { + throw error; + } finally { + await browser.close(); + await server.stop(); + htmlServer.close(); + } + }); + + it("should return 200 for cross-origin no-cors non-module script tag requests with the 'allowedHost' option and 'all' value", async () => { + const compiler = webpack(config); + const devServerOptions = { + port: devServerPort, + allowedHosts: "all", + }; + const server = new Server(devServerOptions, compiler); + + await server.start(); + + // Start a separate server for serving the HTML file + const http = require("http"); + const htmlServer = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + + + + + `); + }); + htmlServer.listen(htmlServerPort, htmlServerHost); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + + page.on("pageerror", (error) => { + pageErrors.push(error); + }); + + const scriptTagRequest = page.waitForResponse( + `http://localhost:${devServerPort}/main.js` + ); + + await page.goto(`http://${htmlServerHost}:${htmlServerPort}`); + + const response = await scriptTagRequest; + + expect(response.status()).toBe(200); + } catch (error) { + throw error; + } finally { + await browser.close(); + await server.stop(); + htmlServer.close(); + } + }); + + it("should return 200 for cross-origin no-cors non-module script tag requests with the `allowedHost` option and the `localhost` value", async () => { + const compiler = webpack(config); + const devServerOptions = { + port: devServerPort, + allowedHosts: ["localhost"], + }; + const server = new Server(devServerOptions, compiler); + + await server.start(); + + // Start a separate server for serving the HTML file + const http = require("http"); + const htmlServer = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + + + + + `); + }); + htmlServer.listen(htmlServerPort, htmlServerHost); + + const { page, browser } = await runBrowser(); + + try { + const pageErrors = []; + + page.on("pageerror", (error) => { + pageErrors.push(error); + }); + + const scriptTagRequest = page.waitForResponse( + `http://localhost:${devServerPort}/main.js` + ); + + await page.goto(`http://${htmlServerHost}:${htmlServerPort}`); + + const response = await scriptTagRequest; + + expect(response.status()).toBe(200); + } catch (error) { + throw error; + } finally { + await browser.close(); + await server.stop(); + htmlServer.close(); + } + }); +}); diff --git a/test/ports-map.js b/test/ports-map.js index 4f1a9af647..6c84fc4267 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -80,6 +80,7 @@ const listOfTests = { "normalize-option": 1, "setup-middlewares-option": 1, "options-request-response": 2, + "cross-origin-request": 2, }; let startPort = 8089; diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index f8aa0d06bb..f2f93d3e5e 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -3459,13 +3459,26 @@ declare class Server { * @param {NextFunction} next */ private setHeaders; + /** + * @private + * @param {string} value + * @returns {boolean} + */ + private isHostAllowed; /** * @private * @param {{ [key: string]: string | undefined }} headers * @param {string} headerToCheck + * @param {boolean} validateHost + * @returns {boolean} + */ + private isValidHost; + /** + * @private + * @param {{ [key: string]: string | undefined }} headers * @returns {boolean} */ - private checkHeader; + private isSameOrigin; /** * @param {ClientConnection[]} clients * @param {string} type