diff --git a/.changeset/tidy-host-headers.md b/.changeset/tidy-host-headers.md new file mode 100644 index 0000000000..0f3562e27e --- /dev/null +++ b/.changeset/tidy-host-headers.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +Validate Host and HTTP/2 authority headers independently of Origin protocol exceptions, including when `allowedHosts` is `"all"`. Handle malformed open-editor URLs with a controlled response and parse their query without relying on the Host header. diff --git a/lib/Server.js b/lib/Server.js index db9e880ab7..486acf79d7 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2230,7 +2230,17 @@ class Server { return; } - const resolveUrl = new URL(req.url, `http://${req.headers.host}`); + let resolveUrl; + + try { + // Only the query is needed; parsing must not depend on the Host header. + resolveUrl = new URL(req.url, "http://localhost"); + } catch { + res.statusCode = 400; + res.end("Invalid URL"); + return; + } + const params = new URLSearchParams(resolveUrl.search); const fileName = params.get("fileName"); @@ -3321,7 +3331,9 @@ class Server { * @returns {boolean} true when host is valid, otherwise false */ isValidHost(headers, headerToCheck, validateHost = true) { - if (this.options.allowedHosts === "all") { + const isOrigin = headerToCheck === "origin"; + + if (isOrigin && this.options.allowedHosts === "all") { return true; } @@ -3333,14 +3345,24 @@ class Server { return false; } - if (DEFAULT_ALLOWED_PROTOCOLS.test(header)) { + if (isOrigin && DEFAULT_ALLOWED_PROTOCOLS.test(header)) { return true; } + // Host and :authority contain a hostname and optional port, never a URL. + if (!isOrigin && /[/\\?#@\s]/.test(header)) { + return false; + } + // use the node url-parser to retrieve the hostname from the host-header. let hostname; try { + // Validate authority syntax before the legacy parser, which accepts invalid ports. + if (!isOrigin && !new URL(`http://${header}`).hostname) { + return false; + } + // if header doesn't have scheme, add // for parsing. // TODO resolve me in the next major release // eslint-disable-next-line n/no-deprecated-api @@ -3357,7 +3379,7 @@ class Server { return false; } - if (this.isHostAllowed(hostname)) { + if (this.options.allowedHosts === "all" || this.isHostAllowed(hostname)) { return true; } @@ -3419,17 +3441,19 @@ class Server { return this.options.allowedHosts === "all"; } - if (DEFAULT_ALLOWED_PROTOCOLS.test(hostHeader)) { - return true; - } + let host; - // 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}`, - false, - true, - ).hostname; + try { + // eslint-disable-next-line n/no-deprecated-api + ({ hostname: host } = url.parse( + // if hostHeader doesn't have scheme, add // for parsing. + /^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`, + false, + true, + )); + } catch { + return false; + } if (host === null) { return false; diff --git a/test/ports-map.js b/test/ports-map.js index ecd1fb24a8..bf4cc155a8 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -83,6 +83,7 @@ const listOfTests = { app: 1, "cross-origin-request": 2, "proxy-web-socket-origin": 2, + "host-header-validation": 1, }; let startPort = 8089; diff --git a/test/server/host-header-validation.test.js b/test/server/host-header-validation.test.js new file mode 100644 index 0000000000..5eddbecd81 --- /dev/null +++ b/test/server/host-header-validation.test.js @@ -0,0 +1,184 @@ +"use strict"; + +const launchEditor = require("launch-editor"); +const request = require("supertest"); +const webpack = require("webpack"); +const Server = require("../../lib/Server"); +const config = require("../fixtures/simple-config/webpack.config"); +const port = require("../ports-map")["host-header-validation"]; + +jest.mock("launch-editor", () => jest.fn()); + +const allowedHostsOptions = ["auto", ["trusted.example"], "all"]; +const invalidHosts = [ + "file:invalid", + "chrome-extension:invalid", + "moz-extension:invalid", + "localhost:invalid", + "localhost:65536", + "http://localhost", + "//localhost", + "user@localhost", + "localhost/path", + "localhost\\path", + "localhost?query", + "localhost#fragment", + " localhost", + "localhost ", +]; + +describe("Host header validation", () => { + for (const allowedHosts of allowedHostsOptions) { + describe(`with allowedHosts ${JSON.stringify(allowedHosts)}`, () => { + const server = new Server({ allowedHosts }, webpack(config)); + + for (const headerName of ["host", ":authority"]) { + for (const value of invalidHosts) { + it(`should reject ${headerName} ${JSON.stringify(value)}`, () => { + expect( + server.isValidHost({ [headerName]: value }, headerName), + ).toBe(false); + }); + } + + for (const value of [ + "localhost", + "localhost:8080", + "127.0.0.1:8080", + "[::1]:8080", + ]) { + it(`should accept ${headerName} ${value}`, () => { + expect( + server.isValidHost({ [headerName]: value }, headerName), + ).toBe(true); + }); + } + } + }); + } + + it("should check hosts named file or chrome-extension against allowedHosts", () => { + for (const host of ["file", "chrome-extension"]) { + expect( + new Server({ allowedHosts: "auto" }, webpack(config)).isValidHost( + { host: `${host}:80` }, + "host", + ), + ).toBe(false); + expect( + new Server({ allowedHosts: [host] }, webpack(config)).isValidHost( + { host: `${host}:80` }, + "host", + ), + ).toBe(true); + } + }); + + it("should not treat a Host with an Origin protocol as same-origin", () => { + const server = new Server({ allowedHosts: "auto" }, webpack(config)); + + for (const host of [ + "file:invalid", + "chrome-extension:invalid", + "file:80", + ]) { + expect(server.isSameOrigin({ host, origin: "http://example.com" })).toBe( + false, + ); + } + }); +}); + +describe("Origin header validation", () => { + for (const allowedHosts of allowedHostsOptions) { + describe(`with allowedHosts ${JSON.stringify(allowedHosts)}`, () => { + const server = new Server({ allowedHosts }, webpack(config)); + + for (const origin of [ + "file:///index.html", + "chrome-extension://extension-id", + "moz-extension://extension-id", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://[::1]:8080", + ]) { + it(`should still accept Origin ${origin}`, () => { + expect(server.isValidHost({ origin }, "origin")).toBe(true); + }); + } + + for (const origin of [undefined, "null", "http://untrusted.example"]) { + it(`should accept Origin ${origin} only when allowedHosts is all`, () => { + expect(server.isValidHost({ origin }, "origin")).toBe( + allowedHosts === "all", + ); + }); + } + + it("should honor the configured allowed hostname for Origin", () => { + expect( + server.isValidHost( + { origin: "http://trusted.example:8080" }, + "origin", + ), + ).toBe(allowedHosts !== "auto"); + }); + }); + } +}); + +describe("Open editor URL handling", () => { + beforeEach(() => { + launchEditor.mockClear(); + }); + + for (const allowedHosts of allowedHostsOptions) { + describe(`with allowedHosts ${JSON.stringify(allowedHosts)}`, () => { + const server = new Server({ allowedHosts, port }, webpack(config)); + + beforeAll(async () => { + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }); + + for (const host of ["file:invalid", "chrome-extension:invalid"]) { + for (const route of ["/webpack-dev-server/open-editor", "/main.js"]) { + it(`should reject Host ${host} on ${route} without exposing a stack trace`, async () => { + const response = await request(server.app) + .get(route) + .set("Host", host); + + expect(response.status).toBe(403); + expect(response.text).toBe("Invalid Host header"); + expect(launchEditor).not.toHaveBeenCalled(); + }); + } + } + + it("should reject malformed request URLs without exposing a stack trace", async () => { + const response = await request(server.app) + .get("/webpack-dev-server/open-editor///[") + .set("Host", "localhost"); + + expect(response.status).toBe(400); + expect(response.text).toBe("Invalid URL"); + expect(launchEditor).not.toHaveBeenCalled(); + }); + + it("should still pass the requested file name to the editor", async () => { + const fileName = "./src/file with spaces.js:3:7"; + const response = await request(server.app) + .get("/webpack-dev-server/open-editor") + .query({ fileName }) + .set("Host", "localhost"); + + expect(response.status).toBe(200); + expect(launchEditor).toHaveBeenCalledTimes(1); + expect(launchEditor).toHaveBeenCalledWith(fileName); + }); + }); + } +});