webpack-dev-server users' source code may be stolen when they access a malicious web site with non-Chromium based browser
Description
webpack-dev-server allows users to use webpack with a development server that provides live reloading. Prior to version 5.2.1, webpack-dev-server users' source code may be stolen when you access a malicious web site with non-Chromium based browser. The Origin header is checked to prevent Cross-site WebSocket hijacking from happening, which was reported by CVE-2018-14732. But webpack-dev-server always allows IP address Origin headers. This allows websites that are served on IP addresses to connect WebSocket. An attacker can obtain source code via a method similar to that used to exploit CVE-2018-14732. Version 5.2.1 contains a patch for the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A webpack-dev-server vulnerability allowed attackers on the same IP network to extract source code via Cross-site WebSocket hijacking; fixed in version 5.2.1.
Vulnerability
CVE-2025-30360 is a bypass of the Origin header check in webpack-dev-server prior to version 5.2.1. The server always allowed requests containing an IP address in the Origin header, even when the request did not come from the expected host. This flaw was introduced when fixing CVE-2018-14732 [4], which originally addressed Cross-site WebSocket hijacking.
Exploitation
An attacker can host a malicious website on a server reachable via an IP address (e.g., on the same local network). When a developer running webpack-dev-server (typically on localhost) visits that malicious site using a non-Chromium browser, the site can connect to the developer's WebSocket endpoint. The attacker does not need credentials beyond the ability to send a WebSocket request to the dev server. The attack is similar to the one described for CVE-2018-14732 [2][4].
Impact
By successfully connecting, the attacker can steal the application's source code served by webpack-dev-server, which often includes proprietary business logic and secrets. The attack requires the victim to use a browser that does not enforce stricter WebSocket origin checks (non-Chromium) and to be on the same IP network as the attacker [1][4].
Mitigation
Version 5.2.1 of webpack-dev-server patches the issue by introducing an allowIP parameter to the checkHeader function, ensuring IP-based origins are only allowed for the Host header, not the Origin header. Users should upgrade to the latest version immediately [2][3].
- GitHub - webpack/webpack-dev-server: Serves a webpack app. Updates the browser on changes. Documentation https://webpack.js.org/configuration/dev-server/.
- Merge commit from fork · webpack/webpack-dev-server@d2575ad
- NVD - CVE-2025-30360
- Source code may be stolen when you access a malicious web site with non-Chromium based browser
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
webpack-dev-servernpm | < 5.2.1 | 5.2.1 |
Affected products
21- Range: <5.2.1
- osv-coords19 versionspkg:apk/chainguard/argo-workflow-clipkg:apk/chainguard/argo-workflow-controllerpkg:apk/chainguard/argo-workflow-controller-compatpkg:apk/chainguard/argo-workflow-executorpkg:apk/chainguard/argo-workflow-executor-compatpkg:apk/chainguard/argo-workflowspkg:apk/chainguard/argo-workflows-known-hostspkg:apk/chainguard/argo-workflows-uipkg:apk/chainguard/argo-workflows-ui-3.7pkg:apk/wolfi/argo-workflow-clipkg:apk/wolfi/argo-workflow-controllerpkg:apk/wolfi/argo-workflow-controller-compatpkg:apk/wolfi/argo-workflow-executorpkg:apk/wolfi/argo-workflow-executor-compatpkg:apk/wolfi/argo-workflowspkg:apk/wolfi/argo-workflows-known-hostspkg:apk/wolfi/argo-workflows-uipkg:apk/wolfi/argo-workflows-ui-3.7pkg:npm/webpack-dev-server
< 3.6.10-r2+ 18 more
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.7.13-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.6.10-r2
- (no CPE)range: < 3.7.13-r2
- (no CPE)range: < 5.2.1
- webpack/webpack-dev-serverv5Range: < 5.2.1
Patches
35c9378bb0127Merge commit from fork
3 files changed · +145 −0
lib/Server.js+26 −0 modified@@ -1970,6 +1970,32 @@ class Server { }, }); + // Register setup cross origin request check for security + 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); + if ( + headers["sec-fetch-mode"] === "no-cors" && + headers["sec-fetch-site"] === "cross-site" + ) { + res.statusCode = 403; + res.end("Cross-Origin request blocked"); + return; + } + + next(); + }, + }); + const isHTTP2 = /** @type {ServerConfiguration<A, S>} */ (this.options.server).type === "http2";
test/e2e/cross-origin-request.test.js+118 −0 added@@ -0,0 +1,118 @@ +"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(` + <html> + <head> + <script src="http://localhost:${devServerPort}/main.js"></script> + </head> + <body></body> + </html> + `); + }); + 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(` + <html> + <head> + <script src="http://localhost:${devServerPort}/main.js" crossorigin></script> + </head> + <body></body> + </html> + `); + }); + 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(); + } + }); +});
test/ports-map.js+1 −0 modified@@ -81,6 +81,7 @@ const listOfTests = { "setup-middlewares-option": 1, "options-request-response": 2, app: 1, + "cross-origin-request": 2, }; let startPort = 8089;
d2575ad8dfedMerge commit from fork
4 files changed · +119 −7
lib/Server.js+12 −7 modified@@ -1960,7 +1960,7 @@ class Server { (req.headers); const headerName = headers[":authority"] ? ":authority" : "host"; - if (this.checkHeader(headers, headerName)) { + if (this.checkHeader(headers, headerName, true)) { next(); return; } @@ -2641,8 +2641,8 @@ class Server { if ( !headers || - !this.checkHeader(headers, "host") || - !this.checkHeader(headers, "origin") + !this.checkHeader(headers, "host", true) || + !this.checkHeader(headers, "origin", false) ) { this.sendMessage([client], "error", "Invalid Host/Origin header"); @@ -3081,9 +3081,10 @@ class Server { * @private * @param {{ [key: string]: string | undefined }} headers * @param {string} headerToCheck + * @param {boolean} allowIP * @returns {boolean} */ - checkHeader(headers, headerToCheck) { + checkHeader(headers, headerToCheck, allowIP) { // allow user to opt out of this security check, at their own risk // by explicitly enabling allowedHosts if (this.options.allowedHosts === "all") { @@ -3110,7 +3111,10 @@ class Server { true, ).hostname; - // always allow requests with explicit IPv4 or IPv6-address. + // allow requests with explicit IPv4 or IPv6-address if allowIP is true. + // Note that IP should not be automatically allowed for Origin headers, + // otherwise an untrusted remote IP host can send requests. + // // A note on IPv6 addresses: // hostHeader will always contain the brackets denoting // an IPv6-address in URLs, @@ -3120,8 +3124,9 @@ class Server { // 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)) || + (allowIP && + hostname !== null && + (ipaddr.IPv4.isValid(hostname) || ipaddr.IPv6.isValid(hostname))) || hostname === "localhost" || (hostname !== null && hostname.endsWith(".localhost")) || hostname === this.options.host;
test/e2e/allowed-hosts.test.js+80 −0 modified@@ -1206,6 +1206,86 @@ describe("allowed hosts", () => { 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", () => {
test/e2e/__snapshots__/allowed-hosts.test.js.snap.webpack5+26 −0 modified@@ -282,6 +282,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`] = `[]`; +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`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.",
types/lib/Server.d.ts+1 −0 modified@@ -1351,6 +1351,7 @@ declare class Server< * @private * @param {{ [key: string]: string | undefined }} headers * @param {string} headerToCheck + * @param {boolean} allowIP * @returns {boolean} */ private checkHeader;
72efaab83381Always allow requests with IP-address as host in checkHost() (#1007)
3 files changed · +17 −1
lib/Server.js+5 −1 modified@@ -7,6 +7,7 @@ const express = require("express"); const fs = require("fs"); const http = require("http"); const httpProxyMiddleware = require("http-proxy-middleware"); +const ip = require("ip"); const serveIndex = require("serve-index"); const historyApiFallback = require("connect-history-api-fallback"); const path = require("path"); @@ -441,8 +442,11 @@ Server.prototype.checkHost = function(headers) { const idx = hostHeader.indexOf(":"); const hostname = idx >= 0 ? hostHeader.substr(0, idx) : hostHeader; + // always allow requests with explicit IP-address + if(ip.isV4Format(hostname)) return true; + // always allow localhost host, for convience - if(hostname === "127.0.0.1" || hostname === "localhost") return true; + if(hostname === "localhost") return true; // allow if hostname is in allowedHosts if(this.allowedHosts && this.allowedHosts.length) {
package.json+1 −0 modified@@ -17,6 +17,7 @@ "html-entities": "^1.2.0", "http-proxy-middleware": "~0.17.4", "internal-ip": "^1.2.0", + "ip": "^1.1.5", "loglevel": "^1.4.1", "opn": "4.0.2", "portfinder": "^1.0.9",
test/Validation.test.js+11 −0 modified@@ -111,6 +111,17 @@ describe("Validation", function() { } }); + it("should allow access for every requests using an IP", function() { + const options = {}; + const headers = { + host: "192.168.1.123" + }; + const server = new Server(compiler, options); + if(!server.checkHost(headers)) { + throw new Error("Validation didn't fail"); + } + }); + it("should not allow hostnames that don't match options.public", function() { const options = { public: "test.host:80",
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-9jgg-88mc-972hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-30360ghsaADVISORY
- github.com/webpack/webpack-dev-server/blob/55220a800ba4e30dbde2d98785ecf4c80b32f711/lib/Server.jsghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-server/commit/5c9378bb01276357d7af208a0856ca2163db188eghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-server/commit/72efaab83381a0e1c4914adf401cbd210b7de7ebghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-server/commit/d2575ad8dfed9207ed810b5ea0ccf465115a2239ghsaWEB
- github.com/webpack/webpack-dev-server/security/advisories/GHSA-9jgg-88mc-972hghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.