Skip to content

Commit 0f49364

Browse files
feat(hot): require a secret on the endpoint, with hot.token
`hot.cors` is answered by `Origin`, and that is its weakness: a browser omits `Origin` and the whole `Sec-Fetch-*` family when the destination is not potentially trustworthy — plain `http` to anything but `localhost`, which `host: "0.0.0.0"` gives you. webpack-dev-server shipped two fixes built on those headers and both were bypassed exactly that way, CVE-2026-6402 and then CVE-2026-14620. A token asks the browser to volunteer nothing. `createHot` mints one per run, `injectHotClient` hands it to the client as another entry-query option, and both wires check it with `timingSafeEqual` before anything else — so a caller without one is told nothing about which origins the endpoint would have allowed. A transport of your own is given it too. Driven end to end: SSE, token: true no token 403 wrong 403 right 200 WS, default no token 403 wrong 403 right CONNECTED WS, token: false no token CONNECTED The two transports default as they did for `cors`: `true` for the WebSocket, which is unreleased so nothing is connecting to it that would not be handed one, and `false` for Server-Sent Events, where requiring one would refuse every client already connecting. Two things the implementation had to account for, both found by the browser tests rather than by reasoning: `inject: false` turns the requirement off. The token reaches the browser through the entry this middleware adds, so with nothing injected there is no way to hand one over and requiring it would refuse a correctly wired client. The client needed the option after all. The first attempt folded the token into the `path` query on the assumption that the client uses that verbatim — true for an injected client, useless for a hand-wired entry, which has no `path` parameter and would not know what a bare `token=` meant. It is a client option like the others now, and `hot.client.token` accepts it in node for a client pointed at another endpoint, which keeps the two name sets identical — a test asserts that and caught the asymmetry. What it does not protect, said in the README rather than left implied: the client reads the token from its entry query, so it is a string in the bundle. Anything that can already read the bundle cross-origin reads the token with it, and over plain `http` to a non-localhost address nothing stops that unless the server sends `Cross-Origin-Resource-Policy`. This hardens every case where the bundle is not readable, and is defence in depth where it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA
1 parent 88626e9 commit 0f49364

23 files changed

Lines changed: 677 additions & 41 deletions

‎.changeset/hot-token.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"webpack-dev-middleware": minor
3+
---
4+
5+
Added `hot.token`: a secret the injected client carries and the hot endpoint
6+
requires, so reaching the stream takes something a page has to have been given
7+
rather than a header the browser may or may not send.
8+
9+
`hot.cors` is answered by `Origin`, and that is its weakness. A browser omits
10+
`Origin` and the whole `Sec-Fetch-*` family when the destination is not
11+
potentially trustworthy — plain `http` to anything but `localhost`, which
12+
`host: "0.0.0.0"` gives you. webpack-dev-server shipped two fixes built on
13+
those headers and both were bypassed exactly that way, CVE-2026-6402 and then
14+
CVE-2026-14620. A token asks the browser to volunteer nothing.
15+
16+
Off by default on both transports, and `true` in the next major release. A
17+
token only reaches the browser on the entry the middleware adds, and `inject`
18+
being on does not mean an entry was added: it is skipped when every entry point
19+
already pulls the client in, when `hot.transport` is a function, and for a
20+
non-web target. Requiring one by default would turn each of those into a `403`
21+
on every client. Set `token: true` to turn it on, and if you do so where no
22+
client was injected the middleware warns rather than leaving you with an
23+
unexplained refusal.
24+
25+
With the client injected, that is all it takes: it is handed the token and puts
26+
it on its connection url. `hot.inject: false` turns the requirement off — the
27+
token travels in the entry the middleware adds, so with nothing injected there
28+
is no way to hand one over. A configuration that lists the client entry itself
29+
is built before the middleware exists and cannot carry a minted token, so give
30+
it a fixed one both sides know, or read the minted one from `instance.token`.
31+
32+
What it does not protect: the client reads the token from its entry query, so
33+
it is a string in the bundle. Anything that can already read the bundle
34+
cross-origin reads the token with it, and over plain `http` to a non-localhost
35+
address nothing stops that unless your server sends
36+
`Cross-Origin-Resource-Policy`. This hardens every case where the bundle is not
37+
readable, and is defence in depth in the case where it is.

‎.cspell.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"expressjs",
4444
"wildcarded",
4545
"jshttp",
46-
"realpath"
46+
"realpath",
47+
"Rsbuild"
4748
],
4849
"ignorePaths": [
4950
"CHANGELOG.md",

‎README.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ The object form accepts these options:
337337
| **[`server`](#hotserver)** | `object` | `undefined` | HTTP server the `'ws'` transport answers upgrades on. |
338338
| **[`progress`](#hotprogress)** | `boolean` | `false` | Publish compilation progress events to the clients. |
339339
| **[`cors`](#hotcors)** | `boolean \| string \| string[] \| RegExp \| function \| object` | see below | Which origins may reach the endpoint from a page on another one, over either transport. |
340+
| **[`token`](#hottoken)** | `boolean \| string` | see below | A secret the injected client carries and the endpoint requires, over either transport. |
340341
| **[`inject`](#hotinject)** | `boolean` | `true` | Add the client entry and `HotModuleReplacementPlugin`. |
341342
| **[`statsOptions`](#hotstatsoptions)** | `object` | `undefined` | Deprecated — do not use; see [`stats`](#stats). |
342343

@@ -537,6 +538,62 @@ A **function** [transport](#hottransport) of your own is handed the option as it
537538
>
538539
> This is about who may reach the endpoint, and nothing else. It does not decide who may reach the **assets** the middleware serves, which is your server's to answer — with a `Cross-Origin-Resource-Policy` response header, or with whatever your framework's own CORS middleware does.
539540
541+
#### `hot.token`
542+
543+
Type: `Boolean | String`
544+
Default: `false`
545+
546+
A secret the injected client carries and the endpoint requires. Reaching the stream then takes something a page has to have been **given**, rather than a header a browser may or may not send.
547+
548+
[`cors`](#hotcors) is answered by `Origin`, and that is the weakness: a browser omits `Origin` and the whole `Sec-Fetch-*` family when the destination is not [potentially trustworthy](https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy) — plain `http` to anything but `localhost`, which is what `host: '0.0.0.0'` gives you. webpack-dev-server shipped two fixes built on those headers and both were bypassed exactly that way ([CVE-2026-6402](https://github.com/advisories/GHSA-79cf-xcqc-c78w), then [CVE-2026-14620](https://github.com/advisories/GHSA-f5vj-f2hx-8m93)). A token asks the browser to volunteer nothing.
549+
550+
**Off by default, on both transports**, and `true` in the next major release. A token only reaches the browser on the entry this middleware adds, and `inject` being on does not mean an entry was added — it is skipped when every entry point already pulls the client in, when [`hot.transport`](#hottransport) is a function, and for a non-web target. Requiring one by default would turn each of those into a `403` on every client.
551+
552+
Turn it on, which is all the normal setup needs — the client is injected, so it is handed the token and uses it:
553+
554+
```js
555+
app.use(middleware(compiler, { hot: { token: true } }));
556+
```
557+
558+
If you turn it on where no client was injected, the middleware says so rather than leaving you with an unexplained `403`:
559+
560+
```
561+
[webpack-dev-middleware] 'hot.token' requires a token on the endpoint, but no
562+
client entry was added to hand one over, so every client will be refused.
563+
```
564+
565+
> [!IMPORTANT]
566+
>
567+
> **What a token does not protect.** The client reads it from its entry query, so it is a string in the bundle. Anything that can already read your bundle cross-origin can read the token out of it — and over plain `http` to a non-`localhost` address, nothing stops that unless your server sends `Cross-Origin-Resource-Policy`. The token hardens every case where the bundle is not readable; where it is, your source has already gone and the stream is the smaller loss. Closing that needs the response header and a `Host` allowlist, which are [your server's](#security) to set.
568+
569+
**Wiring the client yourself.** The token travels in the entry this middleware adds, so `hot.inject: false` turns the requirement off — there would be no way to hand one over, and requiring it would refuse a client you wired correctly.
570+
571+
A configuration that already lists the client as an entry is the other half of that: it is built before the middleware exists, so it cannot carry a token minted per run. Give it one of your own instead, which both sides can know in advance:
572+
573+
```js
574+
const token = "a-secret-of-my-own";
575+
576+
// webpack.config.js
577+
entry: [`webpack-dev-middleware/client?token=${token}`, "./src/index.js"];
578+
579+
// and the middleware
580+
app.use(middleware(compiler, { hot: { transport: "ws", token } }));
581+
```
582+
583+
Or read the minted one off the instance, for a client you serve yourself:
584+
585+
```js
586+
const instance = middleware(compiler, {
587+
hot: { transport: "ws", token: true },
588+
});
589+
590+
app.get("/my-client-config.json", (_req, res) => {
591+
res.json({ token: instance.token });
592+
});
593+
```
594+
595+
`false` requires none, which is the default.
596+
540597
#### `hot.inject`
541598

542599
Type: `Boolean`

‎client-src/index.js‎

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { log, setLogLevel } from "./utils/log.js";
1414
import reloadPage from "./utils/reload.js";
1515
import sendMessage from "./utils/send-message.js";
1616
import stripAnsi from "./utils/strip-ansi.js";
17+
import withToken from "./utils/with-token.js";
1718

1819
/** @typedef {import("./utils/log.js").LogLevel} LogLevel */
1920

@@ -45,6 +46,7 @@ import stripAnsi from "./utils/strip-ansi.js";
4546
* @property {string} urlPrefix prefix of the page-url parameters that turn `hot` and `liveReload` off for one page
4647
* @property {LogLevel} logging logger level
4748
* @property {string} name limit updates to this compilation name
49+
* @property {string} token the secret the endpoint requires, when it requires one, put on the connection url — empty when it requires none
4850
* @property {boolean} autoConnect connect immediately when the entry runs
4951
* @property {number=} reconnect how many times to reconnect before giving up, unset to use the transport's default
5052
* @property {boolean | "circular" | "linear"} progress show an indicator while a rebuild is in progress — `true` and `"circular"` a small badge, `"linear"` a thin bar across the top of the viewport
@@ -62,6 +64,10 @@ const options = {
6264
urlPrefix: "webpack-dev-middleware",
6365
logging: "info",
6466
name: "",
67+
// The secret the endpoint requires, when it requires one. Put on the url
68+
// rather than sent as a header: neither `EventSource` nor `WebSocket` lets a
69+
// page set one.
70+
token: "",
6571
autoConnect: true,
6672
progress: true,
6773
};
@@ -172,6 +178,7 @@ function setOverrides(overrides) {
172178
// Where the page connects, which may be an absolute url rather than a path
173179
// when the endpoint is on another origin.
174180
if (overrides.path) options.path = overrides.path;
181+
if (overrides.token) options.token = overrides.token;
175182
if (overrides.timeout) {
176183
const timeout = Number(overrides.timeout);
177184

@@ -274,19 +281,23 @@ function getClient() {
274281
function createClientSocket() {
275282
const isEventSource = options.transport !== "ws";
276283

277-
return createSocket(getClient(), /** @type {string} */ (options.path), {
278-
clientOptions: { timeout: options.timeout },
279-
// Server-Sent Events are retried for as long as the page is open, at the
280-
// steady interval its watchdog already uses: a dev server is expected to
281-
// come back, and a tab left open over a restart has to find it again.
282-
retries: isEventSource ? Infinity : options.reconnect,
283-
retryDelay: isEventSource
284-
? () => /** @type {number} */ (options.timeout)
285-
: undefined,
286-
onDisconnect: () => {
287-
sendMessage("Close");
284+
return createSocket(
285+
getClient(),
286+
withToken(/** @type {string} */ (options.path), options.token),
287+
{
288+
clientOptions: { timeout: options.timeout },
289+
// Server-Sent Events are retried for as long as the page is open, at the
290+
// steady interval its watchdog already uses: a dev server is expected to
291+
// come back, and a tab left open over a restart has to find it again.
292+
retries: isEventSource ? Infinity : options.reconnect,
293+
retryDelay: isEventSource
294+
? () => /** @type {number} */ (options.timeout)
295+
: undefined,
296+
onDisconnect: () => {
297+
sendMessage("Close");
298+
},
288299
},
289-
});
300+
);
290301
}
291302

292303
const WRAPPER_KEY = "__wdmEventSourceWrapper";

‎client-src/utils/with-token.js‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Put the token on an endpoint url.
3+
*
4+
* On the url because neither `EventSource` nor `WebSocket` lets a page set a
5+
* request header, so there is nowhere else to put it.
6+
*
7+
* Set rather than appended: a path that already carries a `token` would end up
8+
* with two, and the endpoint reads the first — so the one passed here would be
9+
* the one ignored. Taken apart by hand rather than through `URL`, which would
10+
* need a base and would turn a relative path into an absolute one; this keeps
11+
* whatever shape it was given, absolute url, rooted path or relative.
12+
* @param {string} path the endpoint, which may already carry a query, a fragment, or both
13+
* @param {string | undefined} token the token, when the endpoint requires one
14+
* @returns {string} the endpoint to connect to
15+
*/
16+
export default function withToken(path, token) {
17+
if (!token) {
18+
return path;
19+
}
20+
21+
const hashAt = path.indexOf("#");
22+
const hash = hashAt === -1 ? "" : path.slice(hashAt);
23+
const withoutHash = hashAt === -1 ? path : path.slice(0, hashAt);
24+
const queryAt = withoutHash.indexOf("?");
25+
const before = queryAt === -1 ? withoutHash : withoutHash.slice(0, queryAt);
26+
const query = new URLSearchParams(
27+
queryAt === -1 ? "" : withoutHash.slice(queryAt + 1),
28+
);
29+
30+
query.set("token", token);
31+
32+
// Before the fragment, where a query belongs — appending would have put the
33+
// token inside it.
34+
return `${before}?${query}${hash}`;
35+
}

‎src/hot.js‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
* @property {StatsOptions=} statsOptions deprecated, removed in the next major release — webpack stats options used when serializing compilation results
5252
* @property {boolean=} progress publish compilation progress events to the clients
5353
* @property {CorsOption=} cors which origins may reach the endpoint from a page on another one; the local ones by default
54+
* @property {(boolean | string)=} token a secret the injected client carries and the endpoint requires; `true` mints one per run, a string uses that one, `false` requires none. Defaults to `false` on both transports; `true` in the next major release
5455
* @property {boolean=} inject add the hot client entry and `HotModuleReplacementPlugin` to the compilation (default `true`); turn it off to wire them yourself
5556
* @property {HotClientOptions=} client options handed to the browser runtime through its entry query
5657
*/
@@ -128,7 +129,7 @@
128129
* built-in two are made of whatever this returns.
129130
* @template {EXPECTED_ANY} [TClient=StreamClient]
130131
* @callback ClientStreamFactory
131-
* @param {{ path: string, heartbeat: number, cors: CorsOption | undefined }} options the endpoint's path and heartbeat interval, and the origins it is meant to allow
132+
* @param {{ path: string, heartbeat: number, cors: CorsOption | undefined, token: string | false }} options the endpoint's path and heartbeat interval, the origins it is meant to allow, and the token it should require
132133
* @param {Logger} logger logger
133134
* @returns {ClientStream<TClient>} client stream
134135
*/
@@ -141,7 +142,13 @@
141142
// module paths and source frames a failed build reports. Both transports
142143
// honour it now, each the only way it can be honoured on that wire: the event
143144
// stream withholds the grant, and an upgrade is refused.
144-
const { HOT_DEFAULT_CORS_SSE, applyCors, resolveCors } = require("./utils.js");
145+
const {
146+
HOT_DEFAULT_CORS_SSE,
147+
applyCors,
148+
isTokenValid,
149+
resolveCors,
150+
resolveToken,
151+
} = require("./utils.js");
145152

146153
const HOT_DEFAULT_PATH = "/__webpack_hmr";
147154
const HOT_DEFAULT_HEARTBEAT = 10 * 1000;
@@ -241,9 +248,10 @@ function checkClientStream(stream) {
241248
* @param {number} heartbeat heartbeat interval in milliseconds
242249
* @param {Logger} logger logger
243250
* @param {CorsOption=} cors which origins may read the stream, the local ones by default
251+
* @param {(string | false)=} token the token the endpoint requires, or false for none
244252
* @returns {EventStream} event stream
245253
*/
246-
function createEventStream(heartbeat, logger, cors) {
254+
function createEventStream(heartbeat, logger, cors, token = false) {
247255
const corsGrant = resolveCors(cors ?? HOT_DEFAULT_CORS_SSE);
248256
let clientId = 0;
249257
/** @type {Map<number, ServerResponse>} */
@@ -317,6 +325,17 @@ function createEventStream(heartbeat, logger, cors) {
317325
return;
318326
}
319327

328+
// Before the stream, and without the CORS grant: a caller that does not
329+
// carry the token is told nothing about who may read this endpoint.
330+
if (!isTokenValid(token, req)) {
331+
logger.warn(
332+
`A request to "${req.url}" was refused: it carried no valid 'token'. The injected client is given one; a client of your own has to pass it, or set 'hot.token' to a value it can use.`,
333+
);
334+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
335+
res.end("Forbidden");
336+
return;
337+
}
338+
320339
/** @type {Record<string, string>} */
321340
const headers = {
322341
"Content-Type": "text/event-stream;charset=utf-8",
@@ -601,6 +620,7 @@ function publishBundles(bundles, previousBundles, eventStream) {
601620
* @typedef {object} HotInstance
602621
* @property {string} path path the endpoint is served at
603622
* @property {("sse" | "ws" | ClientStreamFactory<EXPECTED_ANY>)} transport how events reach the clients
623+
* @property {string | false} token the secret the endpoint requires, or false when it requires none; the injected client is given it
604624
* @property {(server: HttpServer) => void} attach answer WebSocket upgrades on this server, a no-op for Server-Sent Events
605625
* @property {(req: IncomingMessage, socket: Duplex, head: Buffer) => boolean} handleUpgrade answer one WebSocket upgrade, for a caller that owns the server's `upgrade` event and wants to decide each one; returns false when the request is not the endpoint's, or the transport does not answer upgrades
606626
* @property {(fn: (client: EXPECTED_ANY, req: IncomingMessage) => void) => void} onConnect called with each client once it has joined, and the request it joined with, before anything is published to it
@@ -622,6 +642,13 @@ function createHot(compiler, userOptions, statsOption) {
622642
const transport = options.transport || HOT_DEFAULT_TRANSPORT;
623643
const { cors } = options;
624644
const { statsOptions } = options;
645+
// `inject: false` turns it off: the token reaches the browser through the
646+
// entry this middleware adds, so with nothing injected there is no way to
647+
// hand one over, and requiring it would refuse a client the developer wired
648+
// correctly. Off by default either way — see `HOT_DEFAULT_TOKEN`.
649+
const token = resolveToken(
650+
options.inject === false ? (options.token ?? false) : options.token,
651+
);
625652
const logger = compiler.getInfrastructureLogger("webpack-dev-middleware");
626653

627654
// TODO in the next major release remove `statsOptions` and this warning
@@ -638,7 +665,7 @@ function createHot(compiler, userOptions, statsOption) {
638665

639666
if (typeof transport === "function") {
640667
eventStream = checkClientStream(
641-
transport({ heartbeat, path, cors }, logger),
668+
transport({ heartbeat, path, cors, token }, logger),
642669
);
643670
transportName = "a custom transport";
644671
} else if (transport === "ws") {
@@ -648,10 +675,13 @@ function createHot(compiler, userOptions, statsOption) {
648675

649676
const createWebSocketStream = require("./servers/WebSocketServer.js");
650677

651-
eventStream = createWebSocketStream({ heartbeat, path, cors }, logger);
678+
eventStream = createWebSocketStream(
679+
{ heartbeat, path, cors, token },
680+
logger,
681+
);
652682
transportName = "a WebSocket";
653683
} else {
654-
eventStream = createEventStream(heartbeat, logger, cors);
684+
eventStream = createEventStream(heartbeat, logger, cors, token);
655685
transportName = "Server-Sent Events";
656686
}
657687

@@ -778,6 +808,7 @@ function createHot(compiler, userOptions, statsOption) {
778808
return {
779809
path,
780810
transport,
811+
token,
781812
attach(server) {
782813
if (closed || !eventStream.attach) {
783814
return;

0 commit comments

Comments
 (0)