You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: README.md
+57Lines changed: 57 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -337,6 +337,7 @@ The object form accepts these options:
337
337
|**[`server`](#hotserver)**|`object`|`undefined`| HTTP server the `'ws'` transport answers upgrades on. |
338
338
|**[`progress`](#hotprogress)**|`boolean`|`false`| Publish compilation progress events to the clients. |
339
339
|**[`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. |
340
341
|**[`inject`](#hotinject)**|`boolean`|`true`| Add the client entry and `HotModuleReplacementPlugin`. |
341
342
|**[`statsOptions`](#hotstatsoptions)**|`object`|`undefined`| Deprecated — do not use; see [`stats`](#stats). |
342
343
@@ -537,6 +538,62 @@ A **function** [transport](#hottransport) of your own is handed the option as it
537
538
>
538
539
> 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.
539
540
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:
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:
@@ -45,6 +46,7 @@ import stripAnsi from "./utils/strip-ansi.js";
45
46
* @property {string} urlPrefix prefix of the page-url parameters that turn `hot` and `liveReload` off for one page
46
47
* @property {LogLevel} logging logger level
47
48
* @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
48
50
* @property {boolean} autoConnect connect immediately when the entry runs
49
51
* @property {number=} reconnect how many times to reconnect before giving up, unset to use the transport's default
50
52
* @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 = {
62
64
urlPrefix: "webpack-dev-middleware",
63
65
logging: "info",
64
66
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: "",
65
71
autoConnect: true,
66
72
progress: true,
67
73
};
@@ -172,6 +178,7 @@ function setOverrides(overrides) {
172
178
// Where the page connects, which may be an absolute url rather than a path
Copy file name to clipboardExpand all lines: src/hot.js
+37-6Lines changed: 37 additions & 6 deletions
Original file line number
Diff line number
Diff line change
@@ -51,6 +51,7 @@
51
51
* @property {StatsOptions=} statsOptions deprecated, removed in the next major release — webpack stats options used when serializing compilation results
52
52
* @property {boolean=} progress publish compilation progress events to the clients
53
53
* @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
54
55
* @property {boolean=} inject add the hot client entry and `HotModuleReplacementPlugin` to the compilation (default `true`); turn it off to wire them yourself
55
56
* @property {HotClientOptions=} client options handed to the browser runtime through its entry query
56
57
*/
@@ -128,7 +129,7 @@
128
129
* built-in two are made of whatever this returns.
129
130
* @template {EXPECTED_ANY} [TClient=StreamClient]
130
131
* @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
132
133
* @param {Logger} logger logger
133
134
* @returns {ClientStream<TClient>} client stream
134
135
*/
@@ -141,7 +142,13 @@
141
142
// module paths and source frames a failed build reports. Both transports
142
143
// honour it now, each the only way it can be honoured on that wire: the event
143
144
// stream withholds the grant, and an upgrade is refused.
@@ -317,6 +325,17 @@ function createEventStream(heartbeat, logger, cors) {
317
325
return;
318
326
}
319
327
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.`,
@@ -601,6 +620,7 @@ function publishBundles(bundles, previousBundles, eventStream) {
601
620
* @typedef {object} HotInstance
602
621
* @property {string} path path the endpoint is served at
603
622
* @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
604
624
* @property {(server: HttpServer) => void} attach answer WebSocket upgrades on this server, a no-op for Server-Sent Events
605
625
* @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
606
626
* @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) {
0 commit comments