Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
20 changes: 18 additions & 2 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 and the endpoint requires, over either transport. |
Comment thread
alexander-akait marked this conversation as resolved.
Outdated
| **[`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,7 +589,7 @@ 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.
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 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