From 7d6679cc09c1c9028d060a5486132fe0207d9352 Mon Sep 17 00:00:00 2001 From: arshiya tabasum Date: Wed, 15 Jul 2026 14:56:03 +0530 Subject: [PATCH 1/2] fix(fetch): parse multipart body up to the full boundary delimiter Signed-off-by: arshiya tabasum --- lib/web/fetch/formdata-parser.js | 11 ++++++++--- test/fetch/client-fetch.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/web/fetch/formdata-parser.js b/lib/web/fetch/formdata-parser.js index a4622ae10a3..308ee65577f 100644 --- a/lib/web/fetch/formdata-parser.js +++ b/lib/web/fetch/formdata-parser.js @@ -76,6 +76,12 @@ function multipartFormDataParser (input, mimeType) { const boundary = Buffer.from(`--${boundaryString}`, 'utf8') + // The delimiter that terminates a part's body is the CRLF that precedes the + // next boundary delimiter line, i.e. "\r\n--boundary". Matching the full + // delimiter (not a bare boundary token) is required so boundary-like bytes + // inside a body are not mistaken for a delimiter. + const crlfDashBoundary = Buffer.from(`\r\n--${boundaryString}`, 'utf8') + // 3. Let entry list be an empty entry list. const entryList = [] @@ -140,15 +146,14 @@ function multipartFormDataParser (input, mimeType) { let body // 5.8. Body loop: While position is not past the end of input: - // TODO: the steps here are completely wrong { - const boundaryIndex = input.indexOf(boundary.subarray(2), position.position) + const boundaryIndex = input.indexOf(crlfDashBoundary, position.position) if (boundaryIndex === -1) { throw parsingError('expected boundary after body') } - body = input.subarray(position.position, boundaryIndex - 4) + body = input.subarray(position.position, boundaryIndex) position.position += body.length diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js index 81a8bb4028a..972e9fe729b 100644 --- a/test/fetch/client-fetch.js +++ b/test/fetch/client-fetch.js @@ -268,6 +268,38 @@ test('multipart fromdata non-ascii filed names', async (t) => { t.assert.strictEqual(form.get('fiŝo'), 'value1') }) +test('multipart formdata body containing the boundary token', async (t) => { + t.plan(2) + + const boundary = '----formdata-undici-0.6204674738279623' + // Both values embed the bare boundary token. It only marks a real delimiter + // when it forms a "\r\n--boundary" line, so these bytes must stay in the body. + const field = `a${boundary}b` + const file = `--${boundary}--` + + const request = new Request('http://localhost', { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}` + }, + body: + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="field"\r\n' + + '\r\n' + + `${field}\r\n` + + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="file"; filename="f.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + `${file}\r\n` + + `--${boundary}--` + }) + + const form = await request.formData() + t.assert.strictEqual(form.get('field'), field) + t.assert.strictEqual(await form.get('file').text(), file) +}) + test('busboy emit error', async (t) => { t.plan(1) const formData = new FormData() From 5a40d08609d77f01baef602b78fc789bea54c178 Mon Sep 17 00:00:00 2001 From: arshiya tabasum Date: Thu, 16 Jul 2026 11:19:42 +0530 Subject: [PATCH 2/2] test(fetch): move boundary-token multipart test to test/busboy Drop the explanatory comment on crlfDashBoundary and relocate the regression test out of client-fetch.js into test/busboy. Signed-off-by: arshiya tabasum --- lib/web/fetch/formdata-parser.js | 4 --- test/busboy/formdata-boundary-token.js | 36 ++++++++++++++++++++++++++ test/fetch/client-fetch.js | 32 ----------------------- 3 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 test/busboy/formdata-boundary-token.js diff --git a/lib/web/fetch/formdata-parser.js b/lib/web/fetch/formdata-parser.js index 308ee65577f..c29a26fc34d 100644 --- a/lib/web/fetch/formdata-parser.js +++ b/lib/web/fetch/formdata-parser.js @@ -76,10 +76,6 @@ function multipartFormDataParser (input, mimeType) { const boundary = Buffer.from(`--${boundaryString}`, 'utf8') - // The delimiter that terminates a part's body is the CRLF that precedes the - // next boundary delimiter line, i.e. "\r\n--boundary". Matching the full - // delimiter (not a bare boundary token) is required so boundary-like bytes - // inside a body are not mistaken for a delimiter. const crlfDashBoundary = Buffer.from(`\r\n--${boundaryString}`, 'utf8') // 3. Let entry list be an empty entry list. diff --git a/test/busboy/formdata-boundary-token.js b/test/busboy/formdata-boundary-token.js new file mode 100644 index 00000000000..8e7bbdade9d --- /dev/null +++ b/test/busboy/formdata-boundary-token.js @@ -0,0 +1,36 @@ +'use strict' + +const { test } = require('node:test') +const { Request } = require('../..') + +test('multipart formdata body containing the boundary token', async (t) => { + t.plan(2) + + const boundary = '----formdata-undici-0.6204674738279623' + // Both values embed the bare boundary token. It only marks a real delimiter + // when it forms a "\r\n--boundary" line, so these bytes must stay in the body. + const field = `a${boundary}b` + const file = `--${boundary}--` + + const request = new Request('http://localhost', { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}` + }, + body: + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="field"\r\n' + + '\r\n' + + `${field}\r\n` + + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="file"; filename="f.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + `${file}\r\n` + + `--${boundary}--` + }) + + const form = await request.formData() + t.assert.strictEqual(form.get('field'), field) + t.assert.strictEqual(await form.get('file').text(), file) +}) diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js index 972e9fe729b..81a8bb4028a 100644 --- a/test/fetch/client-fetch.js +++ b/test/fetch/client-fetch.js @@ -268,38 +268,6 @@ test('multipart fromdata non-ascii filed names', async (t) => { t.assert.strictEqual(form.get('fiŝo'), 'value1') }) -test('multipart formdata body containing the boundary token', async (t) => { - t.plan(2) - - const boundary = '----formdata-undici-0.6204674738279623' - // Both values embed the bare boundary token. It only marks a real delimiter - // when it forms a "\r\n--boundary" line, so these bytes must stay in the body. - const field = `a${boundary}b` - const file = `--${boundary}--` - - const request = new Request('http://localhost', { - method: 'POST', - headers: { - 'Content-Type': `multipart/form-data; boundary=${boundary}` - }, - body: - `--${boundary}\r\n` + - 'Content-Disposition: form-data; name="field"\r\n' + - '\r\n' + - `${field}\r\n` + - `--${boundary}\r\n` + - 'Content-Disposition: form-data; name="file"; filename="f.txt"\r\n' + - 'Content-Type: text/plain\r\n' + - '\r\n' + - `${file}\r\n` + - `--${boundary}--` - }) - - const form = await request.formData() - t.assert.strictEqual(form.get('field'), field) - t.assert.strictEqual(await form.get('file').text(), file) -}) - test('busboy emit error', async (t) => { t.plan(1) const formData = new FormData()