webpack-dev-server users' source code may be stolen when they access a malicious web site
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 they access a malicious web site. Because the request for classic script by a script tag is not subject to same origin policy, an attacker can inject a malicious script in their site and run the script. Note that the attacker has to know the port and the output entrypoint script path. Combined with prototype pollution, the attacker can get a reference to the webpack runtime variables. By using Function::toString against the values in __webpack_modules__, the attacker can get the source code. Version 5.2.1 contains a patch for the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Prior to version 5.2.1, webpack-dev-server allowed a malicious site to steal source code via a classic script tag request combined with prototype pollution.
CVE-2025-30359 describes a vulnerability in webpack-dev-server prior to version 5.2.1 that could allow an attacker to steal source code. The root cause originates from the fact that requests for classic scripts via a ` tag are not subject to the same-origin policy. An attacker who knows the target webpack-dev-server's port and the path to an output entrypoint script can inject a tag pointing to that resource into their own malicious web page. When the victim's browser loads that script, the attacker can combine this with a prototype pollution attack to obtain a reference to webpack runtime variables, specifically the __webpack_modules__` object [1][3].
Exploitation requires the attacker to know the correct port and entrypoint script path for the victim's dev server. The attacker hosts a malicious site that, when visited by the victim (who is running a webpack-dev-server on localhost), executes a crafted script. The script uses prototype pollution to intercept the module list, then uses Function::toString() on each module's source code to extract it. The published proof-of-concept demonstrates this exact approach [3].
Impact is high: an attacker can steal the complete source code of a web application running in development mode. Since webpack-dev-server is intended for development only and often includes unminified code with comments, this could leak intellectual property or credentials [1][4].
Mitigation is straightforward: users should upgrade to webpack-dev-server version 5.2.1, which includes the patch. The fix involves tightening host header checking to not allow IP addresses automatically, particularly for the Origin header, which prevents the cross-origin request from being processed [2].
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
25c9378bb0127Merge 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;
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-4v9v-hfq4-rm2vghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-30359ghsaADVISORY
- github.com/webpack/webpack-dev-server/commit/5c9378bb01276357d7af208a0856ca2163db188eghsaWEB
- github.com/webpack/webpack-dev-server/commit/d2575ad8dfed9207ed810b5ea0ccf465115a2239ghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-server/security/advisories/GHSA-4v9v-hfq4-rm2vghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.