Skip to content

[Fix] escape CR and LF in the part Content-Type header - #612

Open
spokodev wants to merge 1 commit into
form-data:masterfrom
spokodev:escape-crlf-content-type
Open

spokodev wants to merge 1 commit into
form-data:masterfrom
spokodev:escape-crlf-content-type

Conversation

@spokodev

Copy link
Copy Markdown

Summary

A part's Content-Type is taken from options.contentType and concatenated verbatim into the multipart header block, with no CR/LF escaping. A caller that forwards an attacker-controlled MIME type — e.g. an upload proxy that preserves the client's Content-Type header — lets an attacker embed CRLF to break out of the header line and smuggle extra headers or whole multipart parts (CWE-93).

This is the sibling of the field-name / filename injection fixed in GHSA-hmw2-7cc7-3qxx (8dff42c). That fix routed field and filename through escapeHeaderParam, but the Content-Type value at lib/form_data.js:197 was left untouched.

Reproduction

const FormData = require('form-data');

const form = new FormData();
const boundary = form.getBoundary();

// attacker controls only the forwarded MIME type of an upload
const injected = 'image/png\r\n\r\nvalue_of_file\r\n'
  + '--' + boundary + '\r\nContent-Disposition: form-data; name="is_admin"\r\n\r\ntrue\r\n'
  + '--' + boundary + '\r\nContent-Disposition: form-data; name="filler"';

form.append('file', Buffer.from('REAL'), { filename: 'photo.png', contentType: injected });

const res = new Response(form.getBuffer(),
  { headers: { 'content-type': 'multipart/form-data; boundary=' + boundary } });
console.log([...(await res.formData()).keys()]);
// before: [ 'file', 'is_admin', 'filler' ]   <- is_admin=true smuggled
// after:  [ 'file' ]

The developer appended one field; a WHATWG-compliant parser (undici / busboy / multer / python-multipart) reads three, including an attacker-injected is_admin=true.

Fix

Escape \r and \n as %0D and %0A on the Content-Type value, matching the WHATWG multipart/form-data encoding already used for name/filename.

Unlike a name/filename (a quoted-string parameter), a media type is not a quoted-string, so ", ; and = are legitimate in it (e.g. text/plain; charset="utf-8") and are deliberately left intact — only CR/LF can terminate the header line, so escaping just those closes the injection without corrupting valid content types. That's why this uses a CR/LF-only escape rather than reusing escapeHeaderParam (which also %22-escapes ").

Tests

Extends test/integration/test-header-injection.js:

  • testContentTypeCRLFInjection — a CRLF-laden content type stays on one header line and smuggles no extra part (fails before this change).
  • testOrdinaryContentTypePreserved — a content type with a quoted charset parameter is emitted unchanged.

Full suite is green (0 errors in 29 files).

A part's Content-Type came straight from `options.contentType` and was
concatenated verbatim into the multipart header block, so a caller that
forwards an attacker-controlled MIME type (e.g. a proxy passing a client's
Content-Type into `append`) could embed CRLF to break out of the header
line and smuggle additional headers or multipart parts (CWE-93).

This is the sibling of the field-name/filename injection fixed in
GHSA-hmw2-7cc7-3qxx; that fix escaped `field` and `filename` but left the
Content-Type value untouched.

Escape `\r` and `\n` as %0D and %0A. Unlike a name/filename, a media type
is not a quoted-string, so `"` and `;` are legitimate (e.g.
`charset="utf-8"`) and are left intact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant