Skip to content

Commit 748540c

Browse files
committed
fix: reject cross-site requests to open-editor and invalidate endpoints
1 parent ba139b8 commit 748540c

4 files changed

Lines changed: 224 additions & 10 deletions

File tree

lib/Server.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2023,6 +2023,12 @@ class Server {
20232023
return;
20242024
}
20252025

2026+
if (!this.#isSameOriginRequest(req)) {
2027+
res.statusCode = 403;
2028+
res.end("Cross-Origin request blocked");
2029+
return;
2030+
}
2031+
20262032
this.invalidate();
20272033

20282034
res.end();
@@ -2044,6 +2050,12 @@ class Server {
20442050
return;
20452051
}
20462052

2053+
if (!this.#isSameOriginRequest(req)) {
2054+
res.statusCode = 403;
2055+
res.end("Cross-Origin request blocked");
2056+
return;
2057+
}
2058+
20472059
if (!req.url) {
20482060
next();
20492061
return;
@@ -3225,6 +3237,36 @@ class Server {
32253237
return origin === host;
32263238
}
32273239

3240+
/**
3241+
* Determines whether a request was initiated from the dev server's own
3242+
* origin, to reject cross-site request forgery on state-changing endpoints.
3243+
* @param {Request} req request
3244+
* @returns {boolean} true when the request can be trusted as same-origin
3245+
*/
3246+
#isSameOriginRequest(req) {
3247+
const headers =
3248+
/** @type {{ [key: string]: string | undefined }} */
3249+
(req.headers);
3250+
3251+
const secFetchSite = headers["sec-fetch-site"];
3252+
3253+
// Prefer `Sec-Fetch-Site`: browsers send it even for same-origin GET
3254+
// `fetch`es (which omit `Origin`), and a cross-site page cannot forge it.
3255+
// `none` is a user-initiated navigation.
3256+
if (typeof secFetchSite === "string") {
3257+
return secFetchSite === "same-origin" || secFetchSite === "none";
3258+
}
3259+
3260+
// Without `Sec-Fetch-*` metadata (non-browser/legacy clients): a request
3261+
// with no `Origin` header was not initiated by another origin's script;
3262+
// otherwise fall back to the `Origin`/`Host` comparison.
3263+
if (typeof headers.origin !== "string") {
3264+
return true;
3265+
}
3266+
3267+
return this.isSameOrigin(headers);
3268+
}
3269+
32283270
/**
32293271
* @param {ClientConnection[]} clients clients
32303272
* @param {string} type type

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
"graceful-fs": "^4.2.11",
7575
"http-proxy-middleware": "^4.1.1",
7676
"ipaddr.js": "^2.3.0",
77-
"launch-editor": "^2.13.2",
77+
"launch-editor": "^2.14.1",
7878
"open": "^11.0.0",
7979
"p-retry": "^8.0.0",
8080
"schema-utils": "^4.3.3",

test/e2e/cross-origin-request.test.js

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import http from "node:http";
2-
import { afterEach, describe, it } from "node:test";
2+
import { afterEach, beforeEach, describe, it } from "node:test";
33
import { expect } from "expect";
44
import webpack from "webpack";
55
import Server from "../../lib/Server.js";
@@ -369,3 +369,175 @@ describe("cross-origin resource policy header", () => {
369369
expect(res.headers["cross-origin-resource-policy"]).toBeUndefined();
370370
});
371371
});
372+
373+
// State-changing internal endpoints must reject cross-site requests so a page
374+
// the developer visits cannot trigger them (CSRF).
375+
describe("cross-site request forgery on state-changing endpoints", () => {
376+
const devServerPort = port1;
377+
378+
let server;
379+
380+
beforeEach(async () => {
381+
const compiler = webpack(config);
382+
server = new Server(
383+
{ port: devServerPort, allowedHosts: "auto" },
384+
compiler,
385+
);
386+
387+
await server.start();
388+
});
389+
390+
afterEach(async () => {
391+
if (server) {
392+
await server.stop();
393+
// Allow the port to be fully released before the next test
394+
await new Promise((resolve) => {
395+
setTimeout(resolve, 100);
396+
});
397+
server = null;
398+
}
399+
});
400+
401+
function request(path, headers = {}) {
402+
return new Promise((resolve, reject) => {
403+
const req = http.get(
404+
`http://localhost:${devServerPort}${path}`,
405+
{ headers },
406+
(res) => {
407+
let body = "";
408+
res.on("data", (chunk) => {
409+
body += chunk;
410+
});
411+
res.on("end", () => {
412+
resolve({ status: res.statusCode, body });
413+
});
414+
},
415+
);
416+
req.on("error", reject);
417+
});
418+
}
419+
420+
for (const endpoint of [
421+
"/webpack-dev-server/invalidate",
422+
"/webpack-dev-server/open-editor",
423+
]) {
424+
it(`should block cross-site requests to ${endpoint}`, async () => {
425+
const res = await request(endpoint, {
426+
"sec-fetch-mode": "cors",
427+
"sec-fetch-site": "cross-site",
428+
});
429+
430+
expect(res.status).toBe(403);
431+
});
432+
433+
it(`should allow same-origin requests to ${endpoint}`, async () => {
434+
const res = await request(endpoint, {
435+
"sec-fetch-mode": "cors",
436+
"sec-fetch-site": "same-origin",
437+
});
438+
439+
expect(res.status).toBe(200);
440+
});
441+
442+
it(`should allow user-initiated navigations to ${endpoint}`, async () => {
443+
const res = await request(endpoint, { "sec-fetch-site": "none" });
444+
445+
expect(res.status).toBe(200);
446+
});
447+
}
448+
449+
it("should block requests with a cross-origin Origin and no Sec-Fetch metadata", async () => {
450+
const res = await request("/webpack-dev-server/invalidate", {
451+
origin: "http://evil.example",
452+
});
453+
454+
expect(res.status).toBe(403);
455+
});
456+
457+
it("should allow requests without Sec-Fetch metadata or Origin (e.g. curl)", async () => {
458+
const res = await request("/webpack-dev-server/invalidate");
459+
460+
expect(res.status).toBe(200);
461+
});
462+
});
463+
464+
// Same as above, but driven by a real browser so the cross-site requests carry
465+
// browser-generated `Sec-Fetch-*` metadata (and the same-origin GET `fetch`
466+
// omits the `Origin` header, exactly like the overlay client).
467+
describe("cross-site request forgery on state-changing endpoints (browser)", () => {
468+
const devServerPort = port1;
469+
const htmlServerPort = port2;
470+
const htmlServerHost = "127.0.0.1";
471+
472+
const openEditorUrl = `http://localhost:${devServerPort}/webpack-dev-server/open-editor`;
473+
const invalidateUrl = `http://localhost:${devServerPort}/webpack-dev-server/invalidate`;
474+
475+
let server;
476+
let htmlServer;
477+
let page;
478+
let browser;
479+
480+
beforeEach(async () => {
481+
const compiler = webpack(config);
482+
server = new Server(
483+
{ port: devServerPort, allowedHosts: "auto" },
484+
compiler,
485+
);
486+
487+
await server.start();
488+
489+
htmlServer = http.createServer((req, res) => {
490+
res.writeHead(200, { "Content-Type": "text/html" });
491+
res.end("<!doctype html><html><body></body></html>");
492+
});
493+
494+
await new Promise((resolve) => {
495+
htmlServer.listen(htmlServerPort, htmlServerHost, resolve);
496+
});
497+
498+
({ page, browser } = await runBrowser());
499+
});
500+
501+
afterEach(async () => {
502+
await browser.close();
503+
htmlServer.close();
504+
await server.stop();
505+
});
506+
507+
it("should block a cross-site iframe navigation to /open-editor", async () => {
508+
await page.goto(`http://${htmlServerHost}:${htmlServerPort}/`);
509+
510+
const responsePromise = page.waitForResponse(openEditorUrl);
511+
await page.evaluate((url) => {
512+
const iframe = document.createElement("iframe");
513+
iframe.src = url;
514+
document.body.append(iframe);
515+
}, openEditorUrl);
516+
517+
expect((await responsePromise).status()).toBe(403);
518+
});
519+
520+
it("should block a cross-site iframe navigation to /invalidate", async () => {
521+
await page.goto(`http://${htmlServerHost}:${htmlServerPort}/`);
522+
523+
const responsePromise = page.waitForResponse(invalidateUrl);
524+
await page.evaluate((url) => {
525+
const iframe = document.createElement("iframe");
526+
iframe.src = url;
527+
document.body.append(iframe);
528+
}, invalidateUrl);
529+
530+
expect((await responsePromise).status()).toBe(403);
531+
});
532+
533+
it("should allow the same-origin overlay fetch to /open-editor", async () => {
534+
await page.goto(`http://localhost:${devServerPort}/`);
535+
536+
const responsePromise = page.waitForResponse(openEditorUrl);
537+
await page.evaluate(() => {
538+
fetch("/webpack-dev-server/open-editor").catch(() => {});
539+
});
540+
541+
expect((await responsePromise).status()).toBe(200);
542+
});
543+
});

0 commit comments

Comments
 (0)