Skip to content
Open
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/overlay-open-editor-token.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"valuemin",
"valuenow",
"webworker",
"wildcarded"
"wildcarded",
"Fapp"
],
"ignorePaths": [
"CHANGELOG.md",
Expand Down
1 change: 1 addition & 0 deletions client-src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
12 changes: 10 additions & 2 deletions client-src/overlay.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -1040,14 +1044,18 @@ function attachRuntimeErrorListeners() {
}

/**
* @param {{ ansiColors?: Record<string, string | string[]>, overlayStyles?: Record<string, string | number>, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string, paginate?: boolean, id?: string }} options options
* @param {{ ansiColors?: Record<string, string | string[]>, overlayStyles?: Record<string, string | number>, 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) {
if (options.trustedTypesPolicyName) {
trustedTypesPolicyName = options.trustedTypesPolicyName;
}

if (options.openEditorToken !== undefined) {
openEditorToken = options.openEditorToken;
}

if (options.openEditorEndpoint !== undefined) {
openEditorEndpoint = options.openEditorEndpoint;
}
Expand Down
28 changes: 28 additions & 0 deletions client-src/utils/open-editor-url.js
Original file line number Diff line number Diff line change
@@ -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}`;
}
39 changes: 39 additions & 0 deletions test/e2e/overlay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
41 changes: 41 additions & 0 deletions test/open-editor-url.test.js
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
3 changes: 2 additions & 1 deletion types/client/overlay.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function showProblems(
source?: string | undefined,
): void;
/**
* @param {{ ansiColors?: Record<string, string | string[]>, overlayStyles?: Record<string, string | number>, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string, paginate?: boolean, id?: string }} options options
* @param {{ ansiColors?: Record<string, string | string[]>, overlayStyles?: Record<string, string | number>, 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: {
Expand All @@ -30,6 +30,7 @@ export default function configureOverlay(options: {
trustedTypesPolicyName?: string;
catchRuntimeError?: boolean | ((error: Error) => boolean);
openEditorEndpoint?: string;
openEditorToken?: string;
paginate?: boolean;
id?: string;
}): {
Expand Down
17 changes: 17 additions & 0 deletions types/client/utils/open-editor-url.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading