From 8210ae67f4c1c5ec16e12024efcf6931d3a07662 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:24:44 +0000 Subject: [PATCH 1/3] feat: send the client's token with the overlay's open-editor request So a server can tell the overlay's request from one another page made. Only to an endpoint on the page's own server, and only when the client was given a token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .changeset/overlay-open-editor-token.md | 5 ++++ .cspell.json | 3 +- client-src/index.js | 1 + client-src/overlay.js | 12 ++++++-- client-src/utils/open-editor-url.js | 20 +++++++++++++ test/e2e/overlay.test.js | 39 +++++++++++++++++++++++++ test/open-editor-url.test.js | 34 +++++++++++++++++++++ types/client/overlay.d.ts | 3 +- types/client/utils/open-editor-url.d.ts | 17 +++++++++++ 9 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 .changeset/overlay-open-editor-token.md create mode 100644 client-src/utils/open-editor-url.js create mode 100644 test/open-editor-url.test.js create mode 100644 types/client/utils/open-editor-url.d.ts diff --git a/.changeset/overlay-open-editor-token.md b/.changeset/overlay-open-editor-token.md new file mode 100644 index 000000000..d822638b1 --- /dev/null +++ b/.changeset/overlay-open-editor-token.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": minor +--- + +The overlay sends the client's token along when it asks `openEditorEndpoint` to open a file, so a server can tell the overlay's request from one another page made. Only to an endpoint on the page's own server (a rooted path), never to an absolute url, and only when the client was given a token. diff --git a/.cspell.json b/.cspell.json index a8fbd04e5..088767409 100644 --- a/.cspell.json +++ b/.cspell.json @@ -47,7 +47,8 @@ "valuemin", "valuenow", "webworker", - "wildcarded" + "wildcarded", + "Fapp" ], "ignorePaths": [ "CHANGELOG.md", diff --git a/client-src/index.js b/client-src/index.js index 4da692e41..87daf0555 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -654,6 +654,7 @@ function createReporter() { ansiColors: options.overlay.ansiColors, overlayStyles: options.overlay.styles, openEditorEndpoint: options.overlay.openEditorEndpoint, + openEditorToken: options.token, paginate: options.overlay.paginate, id: options.overlay.id, } diff --git a/client-src/overlay.js b/client-src/overlay.js index 32561229c..b857f9daf 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -1,6 +1,7 @@ import { problemLine } from "./problem.js"; import theme from "./theme.js"; import ansiHTML, { setColors } from "./utils/ansi-html.js"; +import openEditorUrl from "./utils/open-editor-url.js"; // Re-exported so a consumer that shows problems in the console as well as in // the overlay has one import for both. `./client/problem` is the same thing @@ -231,6 +232,9 @@ let paginate = true; // like webpack-dev-server's `/webpack-dev-server/open-editor`). /** @type {string} */ let openEditorEndpoint = ""; +// The token the client was given, sent along to an endpoint on this server so +// it can tell the overlay's request from one another page made. +let openEditorToken = ""; // Trusted Types support (same pattern as webpack-dev-server): when the page // runs under `require-trusted-types-for 'script'`, every `innerHTML` write @@ -676,7 +680,7 @@ function ensureOverlay() { ); }; - fetch(`${openEditorEndpoint}?fileName=${encodeURIComponent(reference)}`) + fetch(openEditorUrl(openEditorEndpoint, reference, openEditorToken)) .then((response) => { if (!response.ok) { failed(`the endpoint answered ${response.status}`); @@ -1040,7 +1044,7 @@ function attachRuntimeErrorListeners() { } /** - * @param {{ ansiColors?: Record, overlayStyles?: Record, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string, paginate?: boolean, id?: string }} options options + * @param {{ ansiColors?: Record, overlayStyles?: Record, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string, openEditorToken?: string, paginate?: boolean, id?: string }} options options * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api */ export default function configureOverlay(options) { @@ -1048,6 +1052,10 @@ export default function configureOverlay(options) { trustedTypesPolicyName = options.trustedTypesPolicyName; } + if (options.openEditorToken !== undefined) { + openEditorToken = options.openEditorToken; + } + if (options.openEditorEndpoint !== undefined) { openEditorEndpoint = options.openEditorEndpoint; } diff --git a/client-src/utils/open-editor-url.js b/client-src/utils/open-editor-url.js new file mode 100644 index 000000000..9d271fdd2 --- /dev/null +++ b/client-src/utils/open-editor-url.js @@ -0,0 +1,20 @@ +import withToken from "./with-token.js"; + +/** + * The url the overlay asks to open a file reference. + * + * The token goes along only to a path on this page's own server — rooted, + * and not protocol-relative — which is where the hot endpoint that handed it + * over lives. An absolute url can name any server, and the token is not that + * server's to have. + * @param {string} endpoint the `openEditorEndpoint` + * @param {string} reference the `file:line:column` reference clicked + * @param {string=} token the token the client was given, when it was given one + * @returns {string} the url to fetch + */ +export default function openEditorUrl(endpoint, reference, token) { + const url = `${endpoint}?fileName=${encodeURIComponent(reference)}`; + const isOwnServer = endpoint.startsWith("/") && !endpoint.startsWith("//"); + + return isOwnServer ? withToken(url, token) : url; +} diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index f64567636..bd34904a5 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -928,6 +928,45 @@ describe("error overlay (browser)", () => { expect(opened).toEqual(["./app.js:1:7"]); }); + // So the server can tell the overlay's request from one another page made. + it("sends the client's token along to the endpoint", async () => { + /** @type {string[]} */ + const tokens = []; + + // Over the WebSocket transport, where the fixtures require a token. + hotApp = await createHotApp({ + query: '?overlay={"openEditorEndpoint":"/__open-editor"}', + code: acceptedApp("v1"), + transport: "ws", + setup: (server) => { + server.get("/__open-editor", (req, res) => { + tokens.push(String(req.query.token)); + res.end(); + }); + }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("broken for a token {{{"); + const frame = await waitForOverlay(page); + const chip = await frame.waitForSelector("[data-open-file]", { + timeout: 30000, + }); + + await chip.click(); + const start = Date.now(); + while (tokens.length === 0 && Date.now() - start < 30000) { + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + + expect(typeof hotApp.instance.token).toBe("string"); + expect(tokens).toEqual([hotApp.instance.token]); + }); + it("does not mark file chips when no endpoint is configured", async () => { hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); diff --git a/test/open-editor-url.test.js b/test/open-editor-url.test.js new file mode 100644 index 000000000..ac43fb108 --- /dev/null +++ b/test/open-editor-url.test.js @@ -0,0 +1,34 @@ +import openEditorUrl from "../client-src/utils/open-editor-url"; + +describe("the overlay's open-editor url", () => { + it("asks for the reference", () => { + expect(openEditorUrl("/__open-editor", "./app.js:1:7")).toBe( + "/__open-editor?fileName=.%2Fapp.js%3A1%3A7", + ); + }); + + it("carries the token to an endpoint on this server", () => { + expect(openEditorUrl("/__open-editor", "./app.js:1:7", "abc")).toBe( + "/__open-editor?fileName=.%2Fapp.js%3A1%3A7&token=abc", + ); + }); + + // Any server can be named by an absolute url, and the token is not theirs. + it("keeps it from an endpoint that could be another server", () => { + for (const endpoint of [ + "https://elsewhere.example/open", + "//elsewhere.example/open", + "open-editor", + ]) { + expect(openEditorUrl(endpoint, "./app.js:1:7", "abc")).not.toContain( + "token", + ); + } + }); + + it("adds nothing when there is no token", () => { + expect(openEditorUrl("/__open-editor", "./app.js:1:7", "")).not.toContain( + "token", + ); + }); +}); diff --git a/types/client/overlay.d.ts b/types/client/overlay.d.ts index fea2cd2fc..1e504828c 100644 --- a/types/client/overlay.d.ts +++ b/types/client/overlay.d.ts @@ -21,7 +21,7 @@ export function showProblems( source?: string | undefined, ): void; /** - * @param {{ ansiColors?: Record, overlayStyles?: Record, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string, paginate?: boolean, id?: string }} options options + * @param {{ ansiColors?: Record, overlayStyles?: Record, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string, openEditorToken?: string, paginate?: boolean, id?: string }} options options * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api */ export default function configureOverlay(options: { @@ -30,6 +30,7 @@ export default function configureOverlay(options: { trustedTypesPolicyName?: string; catchRuntimeError?: boolean | ((error: Error) => boolean); openEditorEndpoint?: string; + openEditorToken?: string; paginate?: boolean; id?: string; }): { diff --git a/types/client/utils/open-editor-url.d.ts b/types/client/utils/open-editor-url.d.ts new file mode 100644 index 000000000..d95bf6e8e --- /dev/null +++ b/types/client/utils/open-editor-url.d.ts @@ -0,0 +1,17 @@ +/** + * The url the overlay asks to open a file reference. + * + * The token goes along only to a path on this page's own server — rooted, + * and not protocol-relative — which is where the hot endpoint that handed it + * over lives. An absolute url can name any server, and the token is not that + * server's to have. + * @param {string} endpoint the `openEditorEndpoint` + * @param {string} reference the `file:line:column` reference clicked + * @param {string=} token the token the client was given, when it was given one + * @returns {string} the url to fetch + */ +export default function openEditorUrl( + endpoint: string, + reference: string, + token?: string | undefined, +): string; From 119dd7062ea04674689f5f86d137a47a75c4f280 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:55:02 +0000 Subject: [PATCH 2/3] fix: keep the token from an open-editor endpoint starting with /\ A browser reads `/\host` as protocol-relative, like `//host`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- client-src/utils/open-editor-url.js | 8 ++++---- test/open-editor-url.test.js | 1 + types/client/utils/open-editor-url.d.ts | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/client-src/utils/open-editor-url.js b/client-src/utils/open-editor-url.js index 9d271fdd2..0938e4937 100644 --- a/client-src/utils/open-editor-url.js +++ b/client-src/utils/open-editor-url.js @@ -4,9 +4,9 @@ import withToken from "./with-token.js"; * The url the overlay asks to open a file reference. * * The token goes along only to a path on this page's own server — rooted, - * and not protocol-relative — which is where the hot endpoint that handed it - * over lives. An absolute url can name any server, and the token is not that - * server's to have. + * and neither protocol-relative nor what a browser reads as that, `/\` — + * which is where the hot endpoint that handed it over lives. An absolute url + * can name any server, and the token is not that server's to have. * @param {string} endpoint the `openEditorEndpoint` * @param {string} reference the `file:line:column` reference clicked * @param {string=} token the token the client was given, when it was given one @@ -14,7 +14,7 @@ import withToken from "./with-token.js"; */ export default function openEditorUrl(endpoint, reference, token) { const url = `${endpoint}?fileName=${encodeURIComponent(reference)}`; - const isOwnServer = endpoint.startsWith("/") && !endpoint.startsWith("//"); + const isOwnServer = /^\/(?![/\\])/.test(endpoint); return isOwnServer ? withToken(url, token) : url; } diff --git a/test/open-editor-url.test.js b/test/open-editor-url.test.js index ac43fb108..5bb4b9827 100644 --- a/test/open-editor-url.test.js +++ b/test/open-editor-url.test.js @@ -18,6 +18,7 @@ describe("the overlay's open-editor url", () => { for (const endpoint of [ "https://elsewhere.example/open", "//elsewhere.example/open", + "/\\elsewhere.example/open", "open-editor", ]) { expect(openEditorUrl(endpoint, "./app.js:1:7", "abc")).not.toContain( diff --git a/types/client/utils/open-editor-url.d.ts b/types/client/utils/open-editor-url.d.ts index d95bf6e8e..262f37b2f 100644 --- a/types/client/utils/open-editor-url.d.ts +++ b/types/client/utils/open-editor-url.d.ts @@ -2,9 +2,9 @@ * The url the overlay asks to open a file reference. * * The token goes along only to a path on this page's own server — rooted, - * and not protocol-relative — which is where the hot endpoint that handed it - * over lives. An absolute url can name any server, and the token is not that - * server's to have. + * and neither protocol-relative nor what a browser reads as that, `/\` — + * which is where the hot endpoint that handed it over lives. An absolute url + * can name any server, and the token is not that server's to have. * @param {string} endpoint the `openEditorEndpoint` * @param {string} reference the `file:line:column` reference clicked * @param {string=} token the token the client was given, when it was given one From e1e7b496499e74c1824789f8763429bf6ac3c7b7 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:08:14 +0000 Subject: [PATCH 3/3] fix: put fileName into an open-editor endpoint's own query, before its fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- client-src/utils/open-editor-url.js | 16 ++++++++++++---- test/open-editor-url.test.js | 6 ++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/client-src/utils/open-editor-url.js b/client-src/utils/open-editor-url.js index 0938e4937..56112f3c4 100644 --- a/client-src/utils/open-editor-url.js +++ b/client-src/utils/open-editor-url.js @@ -1,5 +1,3 @@ -import withToken from "./with-token.js"; - /** * The url the overlay asks to open a file reference. * @@ -13,8 +11,18 @@ import withToken from "./with-token.js"; * @returns {string} the url to fetch */ export default function openEditorUrl(endpoint, reference, token) { - const url = `${endpoint}?fileName=${encodeURIComponent(reference)}`; const isOwnServer = /^\/(?![/\\])/.test(endpoint); + // Added by hand, into the query the endpoint may already have and before any + // fragment, rather than through `URLSearchParams`, so a space is still sent + // as `%20`, for a route that decodes it with `decodeURIComponent`. + const hashAt = endpoint.indexOf("#"); + const hash = hashAt === -1 ? "" : endpoint.slice(hashAt); + const path = hashAt === -1 ? endpoint : endpoint.slice(0, hashAt); + let query = `fileName=${encodeURIComponent(reference)}`; + + if (isOwnServer && token) { + query += `&token=${encodeURIComponent(token)}`; + } - return isOwnServer ? withToken(url, token) : url; + return `${path}${path.includes("?") ? "&" : "?"}${query}${hash}`; } diff --git a/test/open-editor-url.test.js b/test/open-editor-url.test.js index 5bb4b9827..483026db4 100644 --- a/test/open-editor-url.test.js +++ b/test/open-editor-url.test.js @@ -13,6 +13,12 @@ describe("the overlay's open-editor url", () => { ); }); + it("puts the reference into a query the endpoint already has", () => { + expect( + openEditorUrl("/__open-editor?editor=code#x", "./a b.js", "abc"), + ).toBe("/__open-editor?editor=code&fileName=.%2Fa%20b.js&token=abc#x"); + }); + // Any server can be named by an absolute url, and the token is not theirs. it("keeps it from an endpoint that could be another server", () => { for (const endpoint of [