Skip to content

Commit 3dabcbf

Browse files
committed
fix: reject cross-site requests to open-editor and invalidate endpoints
1 parent 308e853 commit 3dabcbf

6 files changed

Lines changed: 150 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack-dev-server": patch
3+
---
4+
5+
Protect the built-in state-changing routes (`/webpack-dev-server/invalidate` and `/webpack-dev-server/open-editor`) against cross-site request forgery. Requests are now checked with `Sec-Fetch-Site` (falling back to an `Origin`/`Host` comparison when it is absent), so a cross-site page can no longer trigger a rebuild or open a file in the editor. Same-origin requests, user-initiated navigations, and non-browser clients (e.g. curl) are unaffected.

lib/Server.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,6 +2166,12 @@ class Server {
21662166
return;
21672167
}
21682168

2169+
if (!this.#isSameOriginRequest(req)) {
2170+
res.statusCode = 403;
2171+
res.end("Cross-Origin request blocked");
2172+
return;
2173+
}
2174+
21692175
this.invalidate();
21702176

21712177
res.end();
@@ -2187,6 +2193,12 @@ class Server {
21872193
return;
21882194
}
21892195

2196+
if (!this.#isSameOriginRequest(req)) {
2197+
res.statusCode = 403;
2198+
res.end("Cross-Origin request blocked");
2199+
return;
2200+
}
2201+
21902202
if (!req.url) {
21912203
next();
21922204
return;
@@ -3390,6 +3402,36 @@ class Server {
33903402
return origin === host;
33913403
}
33923404

3405+
/**
3406+
* Determines whether a request was initiated from the dev server's own
3407+
* origin, to reject cross-site request forgery on state-changing endpoints.
3408+
* @param {Request} req request
3409+
* @returns {boolean} true when the request can be trusted as same-origin
3410+
*/
3411+
#isSameOriginRequest(req) {
3412+
const headers =
3413+
/** @type {{ [key: string]: string | undefined }} */
3414+
(req.headers);
3415+
3416+
const secFetchSite = headers["sec-fetch-site"];
3417+
3418+
// Prefer `Sec-Fetch-Site`: browsers send it even for same-origin GET
3419+
// `fetch`es (which omit `Origin`), and a cross-site page cannot forge it.
3420+
// `none` is a user-initiated navigation.
3421+
if (typeof secFetchSite === "string") {
3422+
return secFetchSite === "same-origin" || secFetchSite === "none";
3423+
}
3424+
3425+
// Without `Sec-Fetch-*` metadata (non-browser/legacy clients): a request
3426+
// with no `Origin` header was not initiated by another origin's script;
3427+
// otherwise fall back to the `Origin`/`Host` comparison.
3428+
if (typeof headers.origin !== "string") {
3429+
return true;
3430+
}
3431+
3432+
return this.isSameOrigin(headers);
3433+
}
3434+
33933435
/**
33943436
* @param {ClientConnection[]} clients clients
33953437
* @param {string} type type

package-lock.json

Lines changed: 10 additions & 10 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
@@ -62,7 +62,7 @@
6262
"graceful-fs": "^4.2.6",
6363
"http-proxy-middleware": "^2.0.9",
6464
"ipaddr.js": "^2.1.0",
65-
"launch-editor": "^2.6.1",
65+
"launch-editor": "^2.14.1",
6666
"open": "^10.0.3",
6767
"p-retry": "^6.2.0",
6868
"schema-utils": "^4.2.0",

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,94 @@ describe("cross-origin resource policy header", () => {
372372
expect(res.headers["cross-origin-resource-policy"]).toBeUndefined();
373373
});
374374
});
375+
376+
describe("cross-site request forgery on state-changing endpoints", () => {
377+
const devServerPort = port1;
378+
379+
let server;
380+
381+
beforeEach(async () => {
382+
const compiler = webpack(config);
383+
server = new Server(
384+
{ port: devServerPort, allowedHosts: "auto" },
385+
compiler,
386+
);
387+
388+
await server.start();
389+
});
390+
391+
afterEach(async () => {
392+
if (server) {
393+
await server.stop();
394+
// Allow the port to be fully released before the next test
395+
await new Promise((resolve) => {
396+
setTimeout(resolve, 100);
397+
});
398+
server = null;
399+
}
400+
});
401+
402+
function request(path, headers = {}) {
403+
const http = require("node:http");
404+
405+
return new Promise((resolve, reject) => {
406+
const req = http.get(
407+
`http://localhost:${devServerPort}${path}`,
408+
{ headers },
409+
(res) => {
410+
let body = "";
411+
res.on("data", (chunk) => {
412+
body += chunk;
413+
});
414+
res.on("end", () => {
415+
resolve({ status: res.statusCode, body });
416+
});
417+
},
418+
);
419+
req.on("error", reject);
420+
});
421+
}
422+
423+
for (const endpoint of [
424+
"/webpack-dev-server/invalidate",
425+
"/webpack-dev-server/open-editor",
426+
]) {
427+
it(`should block cross-site requests to ${endpoint}`, async () => {
428+
const res = await request(endpoint, {
429+
"sec-fetch-mode": "cors",
430+
"sec-fetch-site": "cross-site",
431+
});
432+
433+
expect(res.status).toBe(403);
434+
});
435+
436+
it(`should allow same-origin requests to ${endpoint}`, async () => {
437+
const res = await request(endpoint, {
438+
"sec-fetch-mode": "cors",
439+
"sec-fetch-site": "same-origin",
440+
});
441+
442+
expect(res.status).toBe(200);
443+
});
444+
445+
it(`should allow user-initiated navigations to ${endpoint}`, async () => {
446+
const res = await request(endpoint, { "sec-fetch-site": "none" });
447+
448+
expect(res.status).toBe(200);
449+
});
450+
}
451+
452+
it("should block requests with a cross-origin Origin and no Sec-Fetch metadata", async () => {
453+
const res = await request("/webpack-dev-server/invalidate", {
454+
origin: "http://evil.example",
455+
});
456+
457+
expect(res.status).toBe(403);
458+
});
459+
460+
it("should allow requests without Sec-Fetch metadata or Origin (e.g. curl)", async () => {
461+
const res = await request("/webpack-dev-server/invalidate");
462+
463+
expect(res.status).toBe(200);
464+
});
465+
});

types/lib/Server.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,6 +1428,7 @@ declare class Server<
14281428
* @param {((err?: Error) => void)=} callback callback
14291429
*/
14301430
stopCallback(callback?: ((err?: Error) => void) | undefined): void;
1431+
#private;
14311432
}
14321433
declare namespace Server {
14331434
export {

0 commit comments

Comments
 (0)