Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hot-token-not-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-middleware": minor
---

`hot.token` also takes `{ value, required }`. With `required: false` a token is minted (or `value` used) and handed to the injected client as usual, but the endpoint does not refuse a client without it, so a server with policy of its own can compare it in `onConnect` alongside, say, the origins it already allows. The instance's new `hot.tokenRequired` says which applies.
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ The object form accepts these options:
| **[`heartbeat`](#hotheartbeat)** | `number` | `10000` | Interval (in milliseconds) between keep-alive frames. |
| **[`progress`](#hotprogress)** | `boolean` | `false` | Publish compilation progress events to the clients. |
| **[`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. |
| **[`token`](#hottoken)** | `boolean \| string` | see below | A secret the injected client carries and the endpoint requires, over either transport. |
| **[`token`](#hottoken)** | `boolean \| string \| object` | see below | A secret the injected client carries; the endpoint requires it unless `required: false`. |
| **[`inject`](#hotinject)** | `"all" \| "plugin" \| "client" \| "none"` | `"all"` | What is added to the compilation: the runtime, `HotModuleReplacementPlugin`, both or neither. |
| **[`statsOptions`](#hotstatsoptions)** | `object` | `undefined` | Deprecated — do not use; see [`stats`](#stats). |

Expand Down Expand Up @@ -589,10 +589,10 @@ A **function** [transport](#hottransport) of your own is handed the option as it

#### `hot.token`

Type: `Boolean | String`
Type: `Boolean | String | { value?: Boolean | String, required?: Boolean }`
Default: `false`

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.
A secret the injected client carries and, unless you set `{ required: false }`, 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. With `required: false` the endpoint enforces nothing and the check is yours, in [`onConnect`](#onconnectfn) — see **Checking it yourself** below.

[`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.

Expand Down Expand Up @@ -629,6 +629,22 @@ entry: [`webpack-dev-middleware/client?token=${token}`, "./src/index.js"];
app.use(middleware(compiler, { hot: { transport: "ws", token } }));
```

**Checking it yourself.** `{ required: false }` mints a token and hands it to the injected client as `true` does, but the endpoint lets a client without it through. That is for a server with policy of its own: it reads the token off the instance and compares it in [`onConnect`](#onconnectfn), alongside whatever else it knows — an origin it already allows, say — and closes a client that has neither. `value` takes the same `true` or string as `hot.token` itself.

```js
const instance = middleware(compiler, {
hot: { transport: "ws", token: { required: false } },
});

instance.onConnect((client, req) => {
const given = new URL(req.url, "http://localhost").searchParams.get("token");

if (given !== instance.token && !isOriginAllowed(req.headers.origin)) {
client.close();
}
});
```

Or read the minted one off the instance, for a client you serve yourself:

```js
Expand Down
30 changes: 24 additions & 6 deletions src/hot.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
* @property {boolean=} dynamicPublicPath prefix the path with the bundle's public path at runtime
*/

/**
* @typedef {object} HotTokenOptions
* @property {(boolean | string)=} value the token: `true` (the default) mints one per run, a string uses that one
* @property {boolean=} required whether the endpoint refuses a client without it (default `true`); `false` still hands it to the injected client, for a server that checks it itself through `onConnect` alongside policy of its own
*/

/**
* @typedef {object} HotOptions
* @property {("sse" | "ws" | ClientStreamFactory<EXPECTED_ANY>)=} transport how events reach the clients, Server-Sent Events by default
Expand All @@ -61,7 +67,7 @@
* @property {StatsOptions=} statsOptions deprecated, removed in the next major release — webpack stats options used when serializing compilation results
* @property {boolean=} progress publish compilation progress events to the clients
* @property {CorsOption=} cors which origins may reach the endpoint from a page on another one; the local ones by default
* @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
* @property {(boolean | string | HotTokenOptions)=} token a secret the injected client carries and the endpoint requires; `true` mints one per run, a string uses that one, `false` requires none, and `{ required: false }` hands one to the client without requiring it. Defaults to `false` on both transports; `true` in the next major release
* @property {("all" | "plugin" | "client" | "none")=} inject what is added to the compilation: the runtime and `HotModuleReplacementPlugin` (`"all"`, the default), the plugin alone for a page that wires a client of its own, the runtime alone for a configuration that applies the plugin itself, or nothing
* @property {HotClientOptions=} client options handed to the browser runtime through its entry query
*/
Expand Down Expand Up @@ -466,7 +472,8 @@ function publishBundles(bundles, previousBundles, eventStream) {
* @typedef {object} HotInstance
* @property {string} path path the endpoint is served at
* @property {("sse" | "ws" | ClientStreamFactory<EXPECTED_ANY>)} transport how events reach the clients
* @property {string | false} token the secret the endpoint requires, or false when it requires none; the injected client is given it
* @property {string | false} token the secret the injected client is given, or false when there is none
* @property {boolean} tokenRequired whether the endpoint refuses a client without the token; false when there is none, or `hot.token.required` is `false`
* @property {(server: HttpServer) => void} attach answer WebSocket upgrades on this server, a no-op for Server-Sent Events
* @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
* @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
Expand Down Expand Up @@ -495,9 +502,19 @@ function createHot(compiler, userOptions, statsOption) {
// correctly. Off by default either way — see `HOT_DEFAULT_TOKEN`.
const injectsClient =
!options.inject || options.inject === "all" || options.inject === "client";
const tokenOption = injectsClient ? options.token : (options.token ?? false);
// `{ required: false }` mints and hands one over all the same, and leaves
// the deciding to whoever owns the server: it reads `token` off the instance
// and compares it in `onConnect`, where it can weigh it against policy of
// its own — an origin it already allows, say — rather than refuse outright.
const isTokenObject = typeof tokenOption === "object" && tokenOption !== null;
const token = resolveToken(
injectsClient ? options.token : (options.token ?? false),
isTokenObject ? (tokenOption.value ?? true) : tokenOption,
);
const tokenRequired =
token !== false && !(isTokenObject && tokenOption.required === false);
// What the endpoint itself enforces.
const requiredToken = tokenRequired ? token : false;
const logger = compiler.getInfrastructureLogger("webpack-dev-middleware");

// TODO in the next major release remove `statsOptions` and this warning
Expand All @@ -514,12 +531,12 @@ function createHot(compiler, userOptions, statsOption) {

if (typeof transport === "function") {
eventStream = checkClientStream(
transport({ heartbeat, path, cors, token }, logger),
transport({ heartbeat, path, cors, token: requiredToken }, logger),
);
transportName = "a custom transport";
} else if (transport === "ws") {
eventStream = requireServer("WebSocketServer")(
{ heartbeat, path, cors, token, ws: options.ws },
{ heartbeat, path, cors, token: requiredToken, ws: options.ws },
logger,
);
transportName = "a WebSocket";
Expand All @@ -528,7 +545,7 @@ function createHot(compiler, userOptions, statsOption) {
heartbeat,
logger,
cors,
token,
requiredToken,
);
transportName = "Server-Sent Events";
}
Expand Down Expand Up @@ -656,6 +673,7 @@ function createHot(compiler, userOptions, statsOption) {
path,
transport,
token,
tokenRequired,
attach(server) {
if (closed || !eventStream.attach) {
return;
Expand Down
3 changes: 2 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ const noop = () => {};
* @property {OnConnect} onConnect called with each client that joins, and the request it joined with
* @property {Publish} publish put a payload of your own on the hot stream, for what a server measures itself — a no-op when `hot` is off
* @property {PublishTo} publishTo put a payload on the hot stream for one client, for answering a single connection — whichever transport is carrying it, and a no-op when `hot` is off
* @property {(string | false | undefined)=} token the secret the hot endpoint requires, for a client of your own to put on the url; false when it requires none, undefined when `hot` is off
* @property {(string | false | undefined)=} token the secret the hot endpoint's client is given, for a client of your own to put on the url; false when there is none, undefined when `hot` is off
* @property {Close} close close
* @property {Context<RequestInternal, ResponseInternal>} context context
*/
Expand Down Expand Up @@ -688,6 +688,7 @@ function wdm(compiler, options = {}, isPlugin = false) {
// The one `createHot` minted just above, so the client it injects and
// the endpoint it serves agree.
token: context.hot?.token,
tokenRequired: context.hot?.tokenRequired,
},
/** @type {Logger} */ (context.logger),
);
Expand Down
2 changes: 1 addition & 1 deletion src/options.check.js

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion src/options.json
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@
"type": "boolean"
},
"token": {
"description": "A secret the injected client carries and the endpoint requires, so reaching the stream takes something a page has to have been given rather than a header the browser may not send. `true` mints one per run, a string uses that one — for a client of your own that has to build the url itself — and `false` requires none. Defaults to `false`, because requiring one would refuse a client this middleware did not inject; `true` in the next major release.",
"description": "A secret the injected client carries and, unless `required` is `false`, the endpoint requires, so reaching the stream takes something a page has to have been given rather than a header the browser may not send. `true` mints one per run, a string uses that one — for a client of your own that has to build the url itself — `false` requires none, and `{ required: false }` hands one to the injected client without requiring it. Defaults to `false`, because requiring one would refuse a client this middleware did not inject; `true` in the next major release.",
"link": "https://github.com/webpack/webpack-dev-middleware#hottoken",
"anyOf": [
{
Expand All @@ -485,6 +485,28 @@
{
"type": "string",
"minLength": 1
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"description": "The token: `true` (the default) mints one per run, a string uses that one.",
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"minLength": 1
}
]
},
"required": {
"description": "Whether the endpoint refuses a client without the token (default `true`). `false` still hands it to the injected client, for a server that checks it itself through `onConnect` alongside policy of its own.",
"type": "boolean"
}
}
}
]
},
Expand Down
4 changes: 2 additions & 2 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1576,7 +1576,7 @@ function resolveClientTransport(request, context) {

/**
* @param {Compiler[]} compilers compilers to modify
* @param {{ path: string, transport: NonNullable<HotOptions["transport"]>, inject?: HotOptions["inject"], client?: HotClientOptions, token?: string | false }} options resolved hot options
* @param {{ path: string, transport: NonNullable<HotOptions["transport"]>, inject?: HotOptions["inject"], client?: HotClientOptions, token?: string | false, tokenRequired?: boolean }} options resolved hot options
* @param {Logger} logger logger
*/
function injectHotClient(compilers, options, logger) {
Expand Down Expand Up @@ -1755,7 +1755,7 @@ function injectHotClient(compilers, options, logger) {
// printing it would invite exactly the wrong fix — pasting a value that is
// already stale — and infrastructure warnings travel into CI output, where
// a secret has no business being.
if (options.token && !injected) {
if (options.token && options.tokenRequired !== false && !injected) {
logger.warn(
"'hot.token' requires a token on the endpoint, but no client entry was added to hand one over, so every client will be refused. Put 'token=<the token>' on the query of the client you added yourself, reading it from the middleware's 'token' property, or set a fixed 'hot.token' both sides know — or 'hot.token: false' to require none.",
);
Expand Down
83 changes: 83 additions & 0 deletions test/hot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,24 @@ describe("createHot", () => {
hot.close();
});

it("lets a stream without the token through when it is not required", () => {
const compiler = makeFakeCompiler();
const hot = createHot(compiler, { token: { required: false } });
/** @type {EXPECTED_OBJECT[]} */
const joined = [];

hot.onConnect((client, req) => {
joined.push(req);
});

attachClient({ handler: hot.handle });

expect(typeof hot.token).toBe("string");
expect(joined).toHaveLength(1);

hot.close();
});

it("publishes nothing to a client a subscriber closed", () => {
const compiler = makeFakeCompiler();
const hot = createHot(compiler, {});
Expand Down Expand Up @@ -1789,6 +1807,71 @@ describe("createHot over a WebSocket", () => {
expect(socket.readyState).toBe(socket.OPEN);
});

// `required: false` is for a server that weighs the token itself, in
// `onConnect`, against policy of its own: the token is minted and handed
// over as usual, and the endpoint lets the handshake through either way.
it("is handed over but not required with `required: false`", async () => {
const endpoint = await serveOverWs(makeFakeCompiler(), {
token: { required: false },
});

expect(typeof endpoint.hot.token).toBe("string");
expect(endpoint.hot.tokenRequired).toBe(false);

/** @type {EXPECTED_ANY[]} */
const joined = [];

endpoint.hot.onConnect((client, req) => {
joined.push(req);
});

const withoutToken = endpoint.url.replace(/\?token=.*$/, "");
const { socket } = await connect(withoutToken);

expect(socket.readyState).toBe(socket.OPEN);

await connect(endpoint.url);
await until(() => joined.length > 1);

// What `onConnect` gets to compare: the token the client carried.
const carried = joined.map((req) =>
new URL(req.url, "http://localhost").searchParams.get("token"),
);

expect(carried).toEqual([null, endpoint.hot.token]);
});

it("takes a value of your own that is not required", async () => {
const endpoint = await serveOverWs(makeFakeCompiler(), {
token: { value: "a-token-of-my-own", required: false },
});

expect(endpoint.hot.token).toBe("a-token-of-my-own");
expect(endpoint.hot.tokenRequired).toBe(false);
});

it("is required with the object form by default", async () => {
const endpoint = await serveOverWs(makeFakeCompiler(), { token: {} });

expect(typeof endpoint.hot.token).toBe("string");
expect(endpoint.hot.tokenRequired).toBe(true);

const withoutToken = endpoint.url.replace(/\?token=.*$/, "");

await expect(connect(withoutToken)).rejects.toThrow(
"Unexpected server response: 403",
);
});

it("is required of nobody with `value: false`", async () => {
const endpoint = await serveOverWs(makeFakeCompiler(), {
token: { value: false },
});

expect(endpoint.hot.token).toBe(false);
expect(endpoint.hot.tokenRequired).toBe(false);
});

// The token is checked before the origin, so a caller without one learns
// nothing about which origins the endpoint would have allowed.
it("is checked before the origin", async () => {
Expand Down
16 changes: 16 additions & 0 deletions test/inject-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,22 @@ describe("injectHotClient", () => {
expect(warnings.join("\n")).toContain("token=<the token>");
});

// Nothing is refused for want of it, so there is nothing to warn about.
it("says nothing about a token that is not required", () => {
injectHotClient(
[documentedSetup()],
{
path: "/__webpack_hmr",
transport: "sse",
token: "a-token-nobody-gets",
tokenRequired: false,
},
logger,
);

expect(warnings.join("\n")).not.toContain("no client entry was added");
});

it("says nothing about a token when the client was injected", () => {
injectHotClient(
[compiler({ entry: "./app.js" })],
Expand Down
Loading
Loading