From 870ed2258dda612c8feeaf8d971afd08233696ad Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 1 Jul 2026 15:05:11 -0500 Subject: [PATCH 1/6] chore: add v5 branch to release workflow triggers --- .changeset/config.json | 2 +- .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/config.json b/.changeset/config.json index 2ea7ce01c3..1037363d36 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,7 @@ "fixed": [], "linked": [], "access": "public", - "baseBranch": "main", + "baseBranch": "v5", "updateInternalDependencies": "patch", "ignore": [] } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47f8065f79..26bf8b0962 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v5 concurrency: ${{ github.workflow }}-${{ github.ref }} From 8b2b9151f41e0d6157c8bd051cf5f16bce5dba17 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 1 Jul 2026 15:58:39 -0500 Subject: [PATCH 2/6] chore: update branch references from v4 to v5 in workflow configuration --- .github/workflows/nodejs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c1c9630651..b8b6d803f1 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -5,12 +5,12 @@ on: branches: - main - next - - v4 + - v5 pull_request: branches: - main - next - - v4 + - v5 permissions: contents: read From 308e853808fc8f5d0722276e1a87aa73c91859a5 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 1 Jul 2026 21:50:36 -0500 Subject: [PATCH 3/6] fix: handle undefined options in Server constructor (#5695) * fix: handle undefined options in Server constructor and update type definitions * fix: handle undefined options in Server constructor and add schema validation for null options --- .changeset/constructor-options-undefined.md | 8 +++++++ lib/Server.js | 4 +++- test/e2e/api.test.js | 23 +++++++++++++++++++++ types/lib/Server.d.ts | 7 +++++-- 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 .changeset/constructor-options-undefined.md diff --git a/.changeset/constructor-options-undefined.md b/.changeset/constructor-options-undefined.md new file mode 100644 index 0000000000..b2fffc250d --- /dev/null +++ b/.changeset/constructor-options-undefined.md @@ -0,0 +1,8 @@ +--- +"webpack-dev-server": patch +--- + +fix: allow `undefined` as the `Server` constructor `options` argument again + +Restores accepting `undefined` (defaulting it to `{}`) for the `options` +argument, so passing a webpack config's optional `devServer` field type-checks and works as before. diff --git a/lib/Server.js b/lib/Server.js index 28f1687fab..d1bdae720c 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -325,10 +325,12 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i; */ class Server { /** - * @param {Configuration} options options + * @param {Configuration | undefined} options options * @param {Compiler | MultiCompiler} compiler compiler */ constructor(options, compiler) { + options = options === undefined ? {} : options; + validate(/** @type {Schema} */ (schema), options, { name: "Dev Server", baseDataPath: "options", diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js index f8a536af49..ff39d887ec 100644 --- a/test/e2e/api.test.js +++ b/test/e2e/api.test.js @@ -71,6 +71,29 @@ describe("API", () => { }); }); + describe("constructor options", () => { + it("should default `undefined` options to `{}`", async () => { + const compiler = webpack(config); + const server = new Server(undefined, compiler); + + expect(server.options).toEqual({}); + + try { + await server.start(); + } finally { + await server.stop(); + } + }); + + it("should reject `null` options via schema validation", () => { + const compiler = webpack(config); + + expect(() => new Server(null, compiler)).toThrow( + /Invalid options object/, + ); + }); + }); + describe("latest async API", () => { it("should work with async API", async () => { const compiler = webpack(config); diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index c807755921..40c4a3f0ff 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -1173,10 +1173,13 @@ declare class Server< */ private static isWebTarget; /** - * @param {Configuration} options options + * @param {Configuration | undefined} options options * @param {Compiler | MultiCompiler} compiler compiler */ - constructor(options: Configuration, compiler: Compiler | MultiCompiler); + constructor( + options: Configuration | undefined, + compiler: Compiler | MultiCompiler, + ); compiler: import("webpack").Compiler | import("webpack").MultiCompiler; /** * @type {ReturnType} From 80cd9eea54975fe632a518d8bd902a260f374e7c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Thu, 2 Jul 2026 12:04:21 -0500 Subject: [PATCH 4/6] fix: reject cross-site requests to open-editor and invalidate endpoints (#5698) --- .../csrf-protect-state-changing-routes.md | 5 + lib/Server.js | 42 +++++++++ package-lock.json | 20 ++-- package.json | 2 +- test/e2e/cross-origin-request.test.js | 91 +++++++++++++++++++ types/lib/Server.d.ts | 1 + 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 .changeset/csrf-protect-state-changing-routes.md diff --git a/.changeset/csrf-protect-state-changing-routes.md b/.changeset/csrf-protect-state-changing-routes.md new file mode 100644 index 0000000000..f925da6f5b --- /dev/null +++ b/.changeset/csrf-protect-state-changing-routes.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +Protect the built-in state-changing routes (`/webpack-dev-server/invalidate` and `/webpack-dev-server/open-editor`) against cross-site request forgery. Requests are now checked with `Sec-Fetch-Site` (falling back to an `Origin`/`Host` comparison when it is absent), so a cross-site page can no longer trigger a rebuild or open a file in the editor. Same-origin requests, user-initiated navigations, and non-browser clients (e.g. curl) are unaffected. diff --git a/lib/Server.js b/lib/Server.js index d1bdae720c..f1c18ae3ec 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -2166,6 +2166,12 @@ class Server { return; } + if (!this.#isSameOriginRequest(req)) { + res.statusCode = 403; + res.end("Cross-Origin request blocked"); + return; + } + this.invalidate(); res.end(); @@ -2187,6 +2193,12 @@ class Server { return; } + if (!this.#isSameOriginRequest(req)) { + res.statusCode = 403; + res.end("Cross-Origin request blocked"); + return; + } + if (!req.url) { next(); return; @@ -3390,6 +3402,36 @@ class Server { return origin === host; } + /** + * Determines whether a request was initiated from the dev server's own + * origin, to reject cross-site request forgery on state-changing endpoints. + * @param {Request} req request + * @returns {boolean} true when the request can be trusted as same-origin + */ + #isSameOriginRequest(req) { + const headers = + /** @type {{ [key: string]: string | undefined }} */ + (req.headers); + + const secFetchSite = headers["sec-fetch-site"]; + + // Prefer `Sec-Fetch-Site`: browsers send it even for same-origin GET + // `fetch`es (which omit `Origin`), and a cross-site page cannot forge it. + // `none` is a user-initiated navigation. + if (typeof secFetchSite === "string") { + return secFetchSite === "same-origin" || secFetchSite === "none"; + } + + // Without `Sec-Fetch-*` metadata (non-browser/legacy clients): a request + // with no `Origin` header was not initiated by another origin's script; + // otherwise fall back to the `Origin`/`Host` comparison. + if (typeof headers.origin !== "string") { + return true; + } + + return this.isSameOrigin(headers); + } + /** * @param {ClientConnection[]} clients clients * @param {string} type type diff --git a/package-lock.json b/package-lock.json index 25597c1167..6ac7b60598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webpack-dev-server", - "version": "5.2.4", + "version": "5.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webpack-dev-server", - "version": "5.2.4", + "version": "5.2.5", "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", @@ -27,7 +27,7 @@ "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", + "launch-editor": "^2.14.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", @@ -15374,13 +15374,13 @@ } }, "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/less": { @@ -19793,9 +19793,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "license": "MIT", "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index e64cc49b5c..574e0e19ac 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", + "launch-editor": "^2.14.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", diff --git a/test/e2e/cross-origin-request.test.js b/test/e2e/cross-origin-request.test.js index 4c94b6db16..75d2f9a500 100644 --- a/test/e2e/cross-origin-request.test.js +++ b/test/e2e/cross-origin-request.test.js @@ -372,3 +372,94 @@ describe("cross-origin resource policy header", () => { expect(res.headers["cross-origin-resource-policy"]).toBeUndefined(); }); }); + +describe("cross-site request forgery on state-changing endpoints", () => { + const devServerPort = port1; + + let server; + + beforeEach(async () => { + const compiler = webpack(config); + server = new Server( + { port: devServerPort, allowedHosts: "auto" }, + compiler, + ); + + await server.start(); + }); + + afterEach(async () => { + if (server) { + await server.stop(); + // Allow the port to be fully released before the next test + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + server = null; + } + }); + + function request(path, headers = {}) { + const http = require("node:http"); + + return new Promise((resolve, reject) => { + const req = http.get( + `http://localhost:${devServerPort}${path}`, + { headers }, + (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + resolve({ status: res.statusCode, body }); + }); + }, + ); + req.on("error", reject); + }); + } + + for (const endpoint of [ + "/webpack-dev-server/invalidate", + "/webpack-dev-server/open-editor", + ]) { + it(`should block cross-site requests to ${endpoint}`, async () => { + const res = await request(endpoint, { + "sec-fetch-mode": "cors", + "sec-fetch-site": "cross-site", + }); + + expect(res.status).toBe(403); + }); + + it(`should allow same-origin requests to ${endpoint}`, async () => { + const res = await request(endpoint, { + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }); + + expect(res.status).toBe(200); + }); + + it(`should allow user-initiated navigations to ${endpoint}`, async () => { + const res = await request(endpoint, { "sec-fetch-site": "none" }); + + expect(res.status).toBe(200); + }); + } + + it("should block requests with a cross-origin Origin and no Sec-Fetch metadata", async () => { + const res = await request("/webpack-dev-server/invalidate", { + origin: "http://evil.example", + }); + + expect(res.status).toBe(403); + }); + + it("should allow requests without Sec-Fetch metadata or Origin (e.g. curl)", async () => { + const res = await request("/webpack-dev-server/invalidate"); + + expect(res.status).toBe(200); + }); +}); diff --git a/types/lib/Server.d.ts b/types/lib/Server.d.ts index 40c4a3f0ff..42bace5a7e 100644 --- a/types/lib/Server.d.ts +++ b/types/lib/Server.d.ts @@ -1428,6 +1428,7 @@ declare class Server< * @param {((err?: Error) => void)=} callback callback */ stopCallback(callback?: ((err?: Error) => void) | undefined): void; + #private; } declare namespace Server { export { From f21ed0f44aceb6132abb591ee8b60d770b6e489f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Thu, 2 Jul 2026 14:21:25 -0500 Subject: [PATCH 5/6] fix: handle malformed Host and Origin headers (#5699) --- .../handle-malformed-host-origin-headers.md | 5 + lib/Server.js | 20 ++-- test/e2e/cross-origin-request.test.js | 102 ++++++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 .changeset/handle-malformed-host-origin-headers.md diff --git a/.changeset/handle-malformed-host-origin-headers.md b/.changeset/handle-malformed-host-origin-headers.md new file mode 100644 index 0000000000..b847961c93 --- /dev/null +++ b/.changeset/handle-malformed-host-origin-headers.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-server": patch +--- + +Handle malformed `Host` and `Origin` header values gracefully when validating requests. diff --git a/lib/Server.js b/lib/Server.js index f1c18ae3ec..9411209601 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -3304,14 +3304,20 @@ class Server { } // use the node url-parser to retrieve the hostname from the host-header. - // TODO resolve me in the next major release - // eslint-disable-next-line n/no-deprecated-api - const { hostname } = url.parse( + let hostname; + + try { // if header doesn't have scheme, add // for parsing. - /^(.+:)?\/\//.test(header) ? header : `//${header}`, - false, - true, - ); + // TODO resolve me in the next major release + // eslint-disable-next-line n/no-deprecated-api + ({ hostname } = url.parse( + /^(.+:)?\/\//.test(header) ? header : `//${header}`, + false, + true, + )); + } catch { + return false; + } if (hostname === null) { return false; diff --git a/test/e2e/cross-origin-request.test.js b/test/e2e/cross-origin-request.test.js index 75d2f9a500..1c66f3d67e 100644 --- a/test/e2e/cross-origin-request.test.js +++ b/test/e2e/cross-origin-request.test.js @@ -463,3 +463,105 @@ describe("cross-site request forgery on state-changing endpoints", () => { expect(res.status).toBe(200); }); }); + +describe("malformed Host/Origin headers", () => { + const devServerPort = port1; + + let server; + + afterEach(async () => { + if (server) { + await server.stop(); + // Allow the port to be fully released before the next test + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + server = null; + } + }); + + it("should reject a WebSocket upgrade with a malformed Origin header without crashing", async () => { + const WebSocket = require("ws"); + const http = require("node:http"); + + const compiler = webpack(config); + server = new Server( + { port: devServerPort, allowedHosts: "auto" }, + compiler, + ); + + await server.start(); + + // A malformed `Origin` (invalid IPv6 literal) used to throw while being + // parsed and take down the whole dev-server process. + await new Promise((resolve) => { + const ws = new WebSocket(`ws://localhost:${devServerPort}/ws`, { + headers: { + host: `localhost:${devServerPort}`, + origin: "http://[::1/", + }, + }); + + ws.on("close", resolve); + ws.on("error", resolve); + }); + + // The server must still be alive: a normal request still succeeds. + const status = await new Promise((resolve, reject) => { + http + .get(`http://localhost:${devServerPort}/main.js`, (res) => { + res.resume(); + resolve(res.statusCode); + }) + .on("error", reject); + }); + + expect(status).toBe(200); + }); + + it("should reject a request with a malformed Host header without crashing", async () => { + const net = require("node:net"); + const http = require("node:http"); + + const compiler = webpack(config); + server = new Server( + { port: devServerPort, allowedHosts: "auto" }, + compiler, + ); + + await server.start(); + + // A malformed `Host` (invalid IPv6 literal) sent on a plain request used to + // throw while being parsed and take down the whole dev-server process. Sent + // over a raw socket so the malformed value reaches the server as-is. + await new Promise((resolve) => { + const socket = net.connect(devServerPort, "127.0.0.1", () => { + socket.write( + [ + "GET /main.js HTTP/1.1", + "Host: [::1", + "Connection: close", + "", + "", + ].join("\r\n"), + ); + }); + + socket.on("data", () => {}); + socket.on("close", resolve); + socket.on("error", resolve); + }); + + // The server must still be alive: a normal request still succeeds. + const status = await new Promise((resolve, reject) => { + http + .get(`http://localhost:${devServerPort}/main.js`, (res) => { + res.resume(); + resolve(res.statusCode); + }) + .on("error", reject); + }); + + expect(status).toBe(200); + }); +}); From 8a37b0ed89d07ca81532188fe19f080a2ee51475 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:43:08 -0500 Subject: [PATCH 6/6] chore(release): new release (#5697) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/constructor-options-undefined.md | 8 -------- .changeset/csrf-protect-state-changing-routes.md | 5 ----- .changeset/handle-malformed-host-origin-headers.md | 5 ----- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 5 files changed, 14 insertions(+), 19 deletions(-) delete mode 100644 .changeset/constructor-options-undefined.md delete mode 100644 .changeset/csrf-protect-state-changing-routes.md delete mode 100644 .changeset/handle-malformed-host-origin-headers.md diff --git a/.changeset/constructor-options-undefined.md b/.changeset/constructor-options-undefined.md deleted file mode 100644 index b2fffc250d..0000000000 --- a/.changeset/constructor-options-undefined.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"webpack-dev-server": patch ---- - -fix: allow `undefined` as the `Server` constructor `options` argument again - -Restores accepting `undefined` (defaulting it to `{}`) for the `options` -argument, so passing a webpack config's optional `devServer` field type-checks and works as before. diff --git a/.changeset/csrf-protect-state-changing-routes.md b/.changeset/csrf-protect-state-changing-routes.md deleted file mode 100644 index f925da6f5b..0000000000 --- a/.changeset/csrf-protect-state-changing-routes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"webpack-dev-server": patch ---- - -Protect the built-in state-changing routes (`/webpack-dev-server/invalidate` and `/webpack-dev-server/open-editor`) against cross-site request forgery. Requests are now checked with `Sec-Fetch-Site` (falling back to an `Origin`/`Host` comparison when it is absent), so a cross-site page can no longer trigger a rebuild or open a file in the editor. Same-origin requests, user-initiated navigations, and non-browser clients (e.g. curl) are unaffected. diff --git a/.changeset/handle-malformed-host-origin-headers.md b/.changeset/handle-malformed-host-origin-headers.md deleted file mode 100644 index b847961c93..0000000000 --- a/.changeset/handle-malformed-host-origin-headers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"webpack-dev-server": patch ---- - -Handle malformed `Host` and `Origin` header values gracefully when validating requests. diff --git a/CHANGELOG.md b/CHANGELOG.md index f0db0618ae..55088d6eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 5.2.6 + +### Patch Changes + +- fix: allow `undefined` as the `Server` constructor `options` argument again (by [@bjohansebas](https://github.com/bjohansebas) in [#5695](https://github.com/webpack/webpack-dev-server/pull/5695)) + + Restores accepting `undefined` (defaulting it to `{}`) for the `options` + argument, so passing a webpack config's optional `devServer` field type-checks and works as before. + +- Protect the built-in state-changing routes (`/webpack-dev-server/invalidate` and `/webpack-dev-server/open-editor`) against cross-site request forgery. Requests are now checked with `Sec-Fetch-Site` (falling back to an `Origin`/`Host` comparison when it is absent), so a cross-site page can no longer trigger a rebuild or open a file in the editor. Same-origin requests, user-initiated navigations, and non-browser clients (e.g. curl) are unaffected. (by [@bjohansebas](https://github.com/bjohansebas) in [#5698](https://github.com/webpack/webpack-dev-server/pull/5698)) + +- Handle malformed `Host` and `Origin` header values gracefully when validating requests. (by [@bjohansebas](https://github.com/bjohansebas) in [#5699](https://github.com/webpack/webpack-dev-server/pull/5699)) + ## 5.2.5 ### Patch Changes diff --git a/package.json b/package.json index 574e0e19ac..4a2e376db1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webpack-dev-server", - "version": "5.2.5", + "version": "5.2.6", "description": "Serves a webpack app. Updates the browser on changes.", "homepage": "https://github.com/webpack/webpack-dev-server#readme", "bugs": "https://github.com/webpack/webpack-dev-server/issues",