Repository navigation
A part is silently dropped when a read ends right after the CR of its last header line (regression in 3.2.2) #233
Description
Affected versions
@fastify/busboy3.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
fieldevent fires regardless of where the byte stream is split. A single-field
form yieldsfield field=valuewhether 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, justfinish.
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 noContent-Typeis 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
dc3hawaii commentedon Oct 16, 2026
Related: #218 (open PR, "drop split CRLFCRLF terminator bytes from the header buffer") fixes the same root cause: the leading
\rof a split\r\n\r\nstays inHeaderParser's buffer. Before 3.2.2 that showed up as a trailing\ron the last header value (what #218 describes). Since 3.2.2's bare-line-break rejection, the same stray\rmakes_parseHeaderdrop the line, so a part whose only header isContent-Dispositiondisappears silently. That is the symptom reported here. Landing #218 should fix both; the repro above makes a good regression test for it.Tony133 commentedon Oct 23, 2026
Fixed in
@fastify/busboy@3.2.4by #218.