h3llh0und

CVE-2026-112085: any open tab could talk to the backend behind webpack-dev-server

webpack-dev-server can forward WebSocket connections to the backend a developer is working on. I found that it forwarded them for any web page, not only for the developer's own app: a site open in another tab could connect through the dev server and read and write that backend, even with the dev server listening only on localhost. The same page asking over plain HTTP was turned away. Fixed in 5.2.7 and 6.1.0.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every connection in it ran on my own machine, over loopback: the published webpack-dev-server packages from npm, a stand-in backend, and a headless Chromium loading a test page from a hostname mapped to the same machine. The issue was reported privately through the project’s security advisory process. It is written up here after the advisory, the fixed releases and the CVE identifier were public.

At a glance. 8.2 High (CVSS 3.1), and the maintainers kept the vector exactly as I filed it. With a WebSocket proxy configured, any web page the developer had open could read from and write to the backend behind webpack-dev-server, including when the dev server listened only on localhost. Fixed in 5.2.7 and 6.1.0. webpack-dev-server is downloaded about 8.9 million times a week from npm.

A front-end developer runs webpack-dev-server on localhost:8080, and the backend they are building runs somewhere else: another port, a container, a staging host. To make the two look like one site, the dev server forwards part of the URL space:

// webpack.config.js
devServer: {
  proxy: [{ context: ["/api"], target: "http://localhost:3000", ws: true }],
},

ws: true tells it to forward WebSocket connections as well as ordinary requests. If the backend streams anything, such as live updates, a GraphQL subscription or a log tail, this is the line that makes it work in development.

Now the developer opens another tab, and that tab is a site I control. It runs two lines of JavaScript against the developer’s machine:

fetch("http://localhost:8080/api/hello");
new WebSocket("ws://localhost:8080/api/socket");

Both go to the same dev server and the same proxy rule. Until 5.2.7 and 6.1.0, they ended differently.

One tab, two answers

The fetch is the case browsers were built for. The request does leave the tab, but the dev server’s answer carries no permission for my origin, so the browser keeps the response away from my page. That is cross-origin resource sharing doing its job. Chromium said so in the console (in my runs the dev server listened on 8181 and up, one port per version):

Access to fetch at 'http://localhost:8181/api/hello' from origin 'http://evil.test:9007'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present...

The WebSocket is a different matter, because browsers do not apply that rule to WebSockets. A page may open a socket to any host it likes. The browser attaches an Origin header naming the page, and it is the server’s job to read that header and refuse a stranger. If the server doesn’t check, the page gets a working two-way channel.

On webpack-dev-server 6.0.0, nobody checked. My page read the first message the backend sent:

page at http://evil.test:9007, dev server 6.0.0 on localhost:8181
  fetch     /api/hello   BLOCKED
  WebSocket /api/socket  READ:INTERNAL-SERVICE-SECRET-42

And the backend’s own log shows who it had been talking to:

{"kind":"upgrade","url":"/api/socket","host":"localhost:8181","origin":"http://evil.test:9007"}

The backend had no reason to suspect anything. It trusts the dev server, because in development the dev server is the developer. The page could send frames as well as read them, so whatever the backend accepts over that socket, the page could send.

The check that existed, on the other socket

webpack-dev-server does check Origin, and carefully. It has two WebSocket endpoints of its own: the hot module replacement socket at /ws, which pushes rebuilds to the browser, and the proxy described above. Its origin check dates from CVE-2025-30360, and it validates every connection before it says anything:

// lib/Server.js, 6.0.0, the hot-reload socket's "connection" handler
if (
  !headers ||
  !this.isValidHost(headers, "host") ||
  !this.isValidHost(headers, "origin") ||
  !this.isSameOrigin(headers)
) {
  this.sendMessage([client], "error", "Invalid Host/Origin header");

The same page, in the same run, tried that socket too, as a control. It got the error message.

For ordinary HTTP requests, the dev server checks the Host header in Express middleware, before any route, the proxy included. That is why a request with a forged Host gets a 403. Keeping a cross-origin response away from a page is then left to the browser, as with the fetch above.

WebSocket connections never pass through that middleware. A WebSocket starts life as an HTTP request asking to be upgraded, and Node’s HTTP server hands those to a separate "upgrade" event. Express never sees them. The hot-reload socket compensates by repeating the checks in its own handler. The proxy’s handler, as of 6.0.0, looked like this:

// lib/Server.js, 6.0.0
(this.server).on("upgrade", (req, socket, head) => {
  if (hmrPath && typeof req.url === "string") {
    const queryIndex = req.url.indexOf("?");
    const pathname =
      queryIndex !== -1 ? req.url.slice(0, queryIndex) : req.url;
    if (pathname === hmrPath) {
      return;
    }
  }
  proxyUpgrade(req, socket, head);
});

There is one test in it, and it is about the path, not the caller. It came from an earlier fix: in June, CVE-2026-9595 stopped a broad proxy rule from capturing the hot-reload socket, and this return is that fix. It keeps the proxy away from the dev server’s own socket. Nothing in the handler asked who was connecting before proxyUpgrade sent the connection on.

So the option that controls who may talk to the dev server, allowedHosts, had no effect on proxied WebSockets in any setting, the default "auto" included.

Localhost did not help

The usual comfort with a dev server is that it listens only on localhost, so nothing outside the machine can reach it. That is true of other machines. It is not true of the developer’s browser, which runs on the same machine and loads pages from everywhere. Every run in this post used a dev server bound to localhost.

What the page needs is for the developer to visit it while the dev server is running, and for the configuration to have a ws: true rule. It has to guess a port and a path, and neither is hard: the default port is 8080, and the proxied prefix is usually something like /api. The page cannot choose where the proxy goes. It reaches the target the developer configured, at any path inside the proxied prefix.

I scored it CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N, 8.2 High. Scope is changed because the service that leaks is the backend, not the dev server. Confidentiality is High because the page reads the socket’s whole stream, the same rating the project gave CVE-2025-30359, where a malicious site could read the developer’s source code. Integrity is only Low: the page can send frames, but what a frame achieves depends on the backend, and my stand-in backend did nothing with them. The report also gave the scores for the two metrics most likely to be disputed, 7.1 with scope unchanged and 6.9 with high attack complexity. The maintainers kept 8.2.

Four packages, one page

For this post I ran the same test against the four published packages on either side of the fix, on 5.x and on 6.x. The attacking page came from http://evil.test:9007, a hostname that Chromium resolved to my own machine, so that the page’s origin was a hostname that is not the dev server’s. Each run also loaded the developer’s own page from the dev server and sent a raw upgrade with a forged Host:

Version Proxied socket, page in another tab Proxied socket, developer’s own page Hot-reload socket, page in another tab Raw upgrade, forged Host
6.0.0 read read refused 101 Switching Protocols
6.1.0 refused read refused 403 Forbidden
5.2.6 read read refused 101 Switching Protocols
5.2.7 refused read refused 403 Forbidden

The controls held on every version. The developer’s own app still gets its socket after the fix, and the hot-reload socket refused the foreign page on all four. On the fixed releases, the backend’s log no longer shows the foreign page’s upgrade at all; the dev server closes it with a 403 before it is forwarded.

One line stayed the same on all four, and it is easy to misread: the foreign page’s fetch reached the backend every time. A blocked fetch means the page could not read the answer. The request was still sent. That is ordinary browser behaviour and not part of this bug, but it is a reminder that a dev backend should not change state on a simple GET.

Four checks, one handler earlier

I reported this on 9 September. Ulises Gascón (UlisesGascon) confirmed it the next morning, and the fix was merged on 17 October as cb1534a, with Sebastian Beltran (bjohansebas) as co-author. It puts the hot-reload socket’s four checks into the proxy’s upgrade handler, ahead of the forward:

if (
  !headers ||
  !this.isValidHost(headers, "host") ||
  !this.isValidHost(headers, "origin") ||
  !this.isSameOrigin(headers)
) {
  if (!socket.destroyed) {
    socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
    socket.destroy();
  }
  return;
}
webSocketProxy.upgrade(req, socket, head);

The commit also closes a path my report did not cover. The proxying library, http-proxy-middleware, can subscribe to upgrade events by itself after the first HTTP request passes through it, which would put a second, unchecked handler next to the new one. The fix now creates the proxy with ws: false and handles every upgrade in the dev server’s own handler. It also covers proxy rules defined through a function, which can turn on WebSockets only after the first request.

The fix shipped in 5.2.7 and 6.1.0 on 18 October. The OpenJS Foundation, the CNA for webpack, reserved CVE-2026-112085 the same day, and the advisory was published on 21 October with me as reporter. Until you can upgrade, removing ws: true from rules whose backend does not need WebSockets closes it.

What the fixed check still trusts

The proxy now follows the same same-origin rule as the hot-reload socket, so it is worth knowing what that rule is. It compares hostnames, not ports. On 6.x, with the default allowedHosts, it also treats localhost, 127.0.0.1 and ::1 as one host. A page served from your own machine on any other port, such as another app’s dev server, counts as the dev server’s own origin. On 6.1.0 and 5.2.7 such a page still opened both sockets in my runs.

That is the rule the project chose for the hot-reload socket, and it was outside my report. It means the boundary the fix draws is the machine, not the app: a page from another site is refused, and a page served from your own machine’s localhost, on any port, is let in. For a dev server that is a reasonable line, as long as you know where it is.


Advisory GHSA-cfqc-fj3v-j925 · CVE CVE-2026-112085 · CVSS 8.2 High (v3.1), AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N · CWE CWE-346, CWE-1385 · Fix cb1534a · Affected < 5.2.7 and >= 6.0.0, < 6.1.0 · Fixed 5.2.7, 6.1.0

Reported 9 September · confirmed 10 September · accepted and fixed 17 October · 5.2.7 and 6.1.0 released 18 October · CVE-2026-112085 reserved by the OpenJS Foundation 18 October · advisory published 21 October, crediting me as reporter · CVE record not yet live at the CVE Program at the time of writing.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und