From 586fdb07a7a815a4cb930fd68eb4f43529ecbe1a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 18 Oct 2026 11:31:09 -0500 Subject: [PATCH] fix: validate host headers and handle malformed editor URLs --- .changeset/tidy-host-headers.md | 5 + lib/Server.js | 29 ++- test/ports-map.js | 1 + test/server/host-header-validation.test.js | 204 +++++++++++++++++++++ 4 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 .changeset/tidy-host-headers.md create mode 100644 test/server/host-header-validation.test.js 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 9b0daa4bd4..d4914aa113 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2092,7 +2092,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"); @@ -3424,7 +3434,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; } @@ -3436,17 +3448,22 @@ 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; + } + const hostname = this.#parseHostnameFromHeader(header); if (hostname === null) { return false; } - if (this.isHostAllowed(hostname)) { + if (this.options.allowedHosts === "all" || this.isHostAllowed(hostname)) { return true; } @@ -3505,10 +3522,6 @@ class Server { return this.options.allowedHosts === "all"; } - if (DEFAULT_ALLOWED_PROTOCOLS.test(hostHeader)) { - return true; - } - const host = this.#parseHostnameFromHeader(hostHeader); if (host === null) { diff --git a/test/ports-map.js b/test/ports-map.js index fa57728a29..3a9ba080e6 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -86,6 +86,7 @@ const listOfTests = { "react-refresh": 1, "universal-target": 1, "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..f4115240ef --- /dev/null +++ b/test/server/host-header-validation.test.js @@ -0,0 +1,204 @@ +import path from "node:path"; +import { after, before, beforeEach, describe, it, mock } from "node:test"; +import { expect } from "expect"; +import { fn } from "jest-mock"; +import request from "supertest"; +import webpack from "webpack"; +import Server from "../../lib/Server.js"; +import config from "../fixtures/simple-config/webpack.config.js"; +import portsMap from "../ports-map.js"; + +const port = portsMap["host-header-validation"]; +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 }); + + 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" }).isValidHost( + { host: `${host}:80` }, + "host", + ), + ).toBe(false); + expect( + new Server({ allowedHosts: [host] }).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" }); + + 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 }); + + 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://localhost:invalid", + "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", () => { + const launchEditor = fn(); + let launchEditorMock; + + before(() => { + launchEditorMock = mock.module("launch-editor", { + defaultExport: launchEditor, + }); + }); + + after(() => { + launchEditorMock.restore(); + }); + + beforeEach(() => { + launchEditor.mockClear(); + }); + + for (const allowedHosts of allowedHostsOptions) { + describe(`with allowedHosts ${JSON.stringify(allowedHosts)}`, () => { + const server = new Server({ allowedHosts, port }, webpack(config)); + + before(async () => { + await server.start(); + }); + + after(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 open the requested file relative to the compiler context", 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( + path.resolve(config.context, fileName), + ); + }); + }); + } +});