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..56112f3c4 --- /dev/null +++ b/client-src/utils/open-editor-url.js @@ -0,0 +1,28 @@ +/** + * 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 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 + * @returns {string} the url to fetch + */ +export default function openEditorUrl(endpoint, reference, token) { + 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 `${path}${path.includes("?") ? "&" : "?"}${query}${hash}`; +} 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..483026db4 --- /dev/null +++ b/test/open-editor-url.test.js @@ -0,0 +1,41 @@ +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", + ); + }); + + 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 [ + "https://elsewhere.example/open", + "//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..262f37b2f --- /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 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 + * @returns {string} the url to fetch + */ +export default function openEditorUrl( + endpoint: string, + reference: string, + token?: string | undefined, +): string;