Skip to content

A part is silently dropped when a read ends right after the CR of its last header line (regression in 3.2.2) #233

Closed

Description

@dc3hawaii

Affected versions

  • @fastify/busboy 3.2.2 and 3.2.3 (reproduced on both).
  • Not affected: 3.2.1 (loses no parts on the same input).
  • Reproduced on Node.js v22.22.3.

Summary

3.2.2 added "reject bare line breaks" to deps/dicer/lib/HeaderParser.js. A part's header block ends
with \r\n\r\n. If a write (a socket read, in production) ends right after the first \r of that
terminator, HeaderParser.push has already appended that \r to its buffer. The next write starts
\n\r\n and completes the terminator through the tail check, so the stray \r stays in the
buffer. _parseHeader then sees a final line ending in a bare \r, treats it as a bare line break,
and returns before storing that line.

If the dropped line was the part's only header (Content-Disposition), the part has no disposition
and busboy skips it silently: no field/file event, no error. A data part vanishes with no
diagnostic.

Minimal standalone repro

Full repro script (busboy-header-split.cjs) is at the end of this issue.

npm i @fastify/busboy@3.2.3
node busboy-header-split.cjs

It parses one single-field form three ways: as one write, split once at the byte right after the CR
that ends the Content-Disposition line, and then split once at every possible offset.

const busboy = require('@fastify/busboy');
const body = Buffer.from('--b\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n--b--\r\n');
const cut = body.indexOf('\r\n\r\n') + 1; // right after the CR that ends the header line
const bb = busboy({ headers: { 'content-type': 'multipart/form-data; boundary=b' } });
bb.on('field', (name, value) => console.log('field', name, value));
bb.on('finish', () => console.log('finish'));
bb.write(body.subarray(0, cut)); bb.end(body.subarray(cut));

Expected vs actual

  • Expected: the field event fires regardless of where the byte stream is split. A single-field
    form yields field field=value whether delivered in one write or two.
  • Actual: when the split falls right after the CR of the header line's CRLF, the field is
    dropped. No event, no error, just finish.

Actual output (Node v22.22.3)

node v22.22.3, @fastify/busboy 3.2.3
one write:               field field=value
split at byte 50 (after CR): (no parts)
split offsets (1..66) that lose the field: 50
RESULT: reproduced

For contrast, 3.2.1 on the same input:

node v22.22.3, @fastify/busboy 3.2.1
one write:               field field=value
split at byte 50 (after CR): field field=value
split offsets (1..66) that lose the field: none
RESULT: not reproduced

Exactly one offset per part triggers it (the byte after the CR of its last header line), so over real
HTTP it is rare but not negligible: it needs a read boundary at that exact byte, roughly one chance
per part per read — on the order of one upload in tens of thousands with 64 KB reads, and more often
behind smaller proxy buffers.

Impact

Data integrity / silent data loss. A part whose last (or only) header lands on that split boundary is
dropped with no error. In practice:

  • A field part is skipped and its value is lost.
  • A browser file part normally ends its headers with Content-Type, so a split there drops the mime
    type (the data survives); a file part sent with no Content-Type is dropped entirely.

There is no exception and no event to detect it, so consumers cannot distinguish a dropped part from
one the client never sent.

Suggested fix

The regression is in _parseHeader's handling of a trailing bare \r that is actually the first
half of a CRLF split across two writes. The bare-line-break rejection added in 3.2.2 should not fire
on a lone trailing \r at the end of the accumulated header buffer when it is the boundary between
the header block and its terminator; that \r is part of the \r\n\r\n terminator, not a bare line
break within a header value. Buffering a trailing \r until the next byte is seen (as 3.2.1
effectively did) before deciding whether it is a bare line break would restore correct behaviour
while keeping the hardening for genuine bare line breaks inside header values.

Full repro script: busboy-header-split.cjs

// Standalone repro: @fastify/busboy 3.2.2+ drops a part's last header line when a write ends right
// after the CR of that line's CRLF. If that line is the part's only header (Content-Disposition),
// the part is skipped silently.
//
//   npm i @fastify/busboy@3.2.3
//   node busboy-header-split.cjs
const busboy = require('@fastify/busboy');
console.log(`node ${process.version}, @fastify/busboy ${require('@fastify/busboy/package.json').version}`);

const body = Buffer.from('--b\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n--b--\r\n');

function parse (chunks) {
  return new Promise(resolve => {
    const events = [];
    const bb = busboy({ headers: { 'content-type': 'multipart/form-data; boundary=b' } });
    bb.on('field', (name, value) => events.push(`field ${name}=${value}`));
    bb.on('file', (name, file) => { events.push(`file ${name}`); file.resume(); });
    bb.on('error', err => events.push(`error ${err.message}`));
    bb.on('finish', () => resolve(events.join(', ') || '(no parts)'));
    for (const c of chunks.slice(0, -1)) bb.write(c);
    bb.end(chunks[chunks.length - 1]);
  });
}

(async () => {
  const cut = body.indexOf('\r\n\r\n') + 1; // right after the CR that ends the header line
  console.log('one write:              ', await parse([body]));
  console.log(`split at byte ${cut} (after CR):`, await parse([body.subarray(0, cut), body.subarray(cut)]));

  const lost = [];
  for (let i = 1; i < body.length; i++) {
    if (!(await parse([body.subarray(0, i), body.subarray(i)])).includes('field field=value')) lost.push(i);
  }
  console.log(`split offsets (1..${body.length - 1}) that lose the field:`, lost.join(', ') || 'none');
  console.log(lost.length ? 'RESULT: reproduced' : 'RESULT: not reproduced');
})();

Activity

  1. dc3hawaii commented on Oct 16, 2026

    @dc3hawaii
    Author

    Related: #218 (open PR, "drop split CRLFCRLF terminator bytes from the header buffer") fixes the same root cause: the leading \r of a split \r\n\r\n stays in HeaderParser's buffer. Before 3.2.2 that showed up as a trailing \r on the last header value (what #218 describes). Since 3.2.2's bare-line-break rejection, the same stray \r makes _parseHeader drop the line, so a part whose only header is Content-Disposition disappears silently. That is the symptom reported here. Landing #218 should fix both; the repro above makes a good regression test for it.

  2. Tony133 commented on Oct 23, 2026

    @Tony133
    Member

    Fixed in @fastify/busboy@3.2.4 by #218.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions