From 03bae5e77e1e8adfa5ae8de9e59c5ae8f8cb5b41 Mon Sep 17 00:00:00 2001 From: Matthew Aitken Date: Mon, 11 May 2026 14:30:52 -0400 Subject: [PATCH 1/2] cache formdata boundary I hate low quality ai PRs --- lib/web/fetch/body.js | 8 ++++++-- lib/web/fetch/formdata.js | 23 +++++++++++++++++++++-- test/fetch/issue-4065.js | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 test/fetch/issue-4065.js diff --git a/lib/web/fetch/body.js b/lib/web/fetch/body.js index 7e08b29fd6c..8ee2ea1a6b1 100644 --- a/lib/web/fetch/body.js +++ b/lib/web/fetch/body.js @@ -7,7 +7,7 @@ const { fullyReadBody, extractMimeType } = require('./util') -const { FormData, setFormDataState } = require('./formdata') +const { FormData, setFormDataState, getFormDataBoundary, setFormDataBoundary } = require('./formdata') const { webidl } = require('../webidl') const assert = require('node:assert') const { isErrored, isDisturbed } = require('node:stream') @@ -106,7 +106,11 @@ function extractBody (object, keepalive = false) { // Set source to a copy of the bytes held by object. source = webidl.util.getCopyOfBytesHeldByBufferSource(object) } else if (webidl.is.FormData(object)) { - const boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}` + let boundary = getFormDataBoundary(object) + if (boundary === null) { + boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}` + setFormDataBoundary(object, boundary) + } const prefix = `--${boundary}\r\nContent-Disposition: form-data` /*! formdata-polyfill. MIT License. Jimmy Wärting */ diff --git a/lib/web/fetch/formdata.js b/lib/web/fetch/formdata.js index c21fb06a3ee..6dd828a898e 100644 --- a/lib/web/fetch/formdata.js +++ b/lib/web/fetch/formdata.js @@ -8,6 +8,7 @@ const nodeUtil = require('node:util') // https://xhr.spec.whatwg.org/#formdata class FormData { #state = [] + #boundary = null constructor (form = undefined) { webidl.util.markAsUncloneable(this) @@ -192,11 +193,29 @@ class FormData { static setFormDataState (formData, newState) { formData.#state = newState } + + /** + * @param {FormData} formData + * @returns {string | null} + */ + static getFormDataBoundary (formData) { + return formData.#boundary + } + + /** + * @param {FormData} formData + * @param {string} boundary + */ + static setFormDataBoundary (formData, boundary) { + formData.#boundary = boundary + } } -const { getFormDataState, setFormDataState } = FormData +const { getFormDataState, setFormDataState, getFormDataBoundary, setFormDataBoundary } = FormData Reflect.deleteProperty(FormData, 'getFormDataState') Reflect.deleteProperty(FormData, 'setFormDataState') +Reflect.deleteProperty(FormData, 'getFormDataBoundary') +Reflect.deleteProperty(FormData, 'setFormDataBoundary') iteratorMixin('FormData', FormData, getFormDataState, 'name', 'value') @@ -256,4 +275,4 @@ function makeEntry (name, value, filename) { webidl.is.FormData = webidl.util.MakeTypeAssertion(FormData) -module.exports = { FormData, makeEntry, setFormDataState } +module.exports = { FormData, makeEntry, setFormDataState, getFormDataBoundary, setFormDataBoundary } diff --git a/test/fetch/issue-4065.js b/test/fetch/issue-4065.js new file mode 100644 index 00000000000..b25b42efda5 --- /dev/null +++ b/test/fetch/issue-4065.js @@ -0,0 +1,38 @@ +'use strict' + +const { test } = require('node:test') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { fetch, FormData } = require('../..') + +// https://github.com/nodejs/undici/issues/4065 +test('multipart/form-data boundary is stable across a 307 redirect', async (t) => { + const server = createServer((req, res) => { + if (req.method === 'POST' && req.url === '/first') { + res.writeHead(307, { + Location: `http://localhost:${server.address().port}/second` + }) + res.end() + return + } + + if (req.method === 'POST' && req.url === '/second') { + res.setHeader('content-type', req.headers['content-type']) + req.pipe(res).on('end', () => res.end()) + } + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + const formData = new FormData() + formData.append('test', 'data') + + const response = await fetch(`http://localhost:${server.address().port}/first`, { + method: 'POST', + body: formData, + redirect: 'follow' + }) + + await t.assert.doesNotReject(response.formData()) +}) From aac9e77df0dfc43e807ec4d63171727736175fdd Mon Sep 17 00:00:00 2001 From: Matthew Aitken Date: Mon, 11 May 2026 14:40:32 -0400 Subject: [PATCH 2/2] generate boundary inside FormData --- lib/web/fetch/body.js | 13 ++----------- lib/web/fetch/formdata.js | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/lib/web/fetch/body.js b/lib/web/fetch/body.js index 8ee2ea1a6b1..525bafb2c3c 100644 --- a/lib/web/fetch/body.js +++ b/lib/web/fetch/body.js @@ -7,7 +7,7 @@ const { fullyReadBody, extractMimeType } = require('./util') -const { FormData, setFormDataState, getFormDataBoundary, setFormDataBoundary } = require('./formdata') +const { FormData, setFormDataState, getFormDataBoundary } = require('./formdata') const { webidl } = require('../webidl') const assert = require('node:assert') const { isErrored, isDisturbed } = require('node:stream') @@ -16,11 +16,6 @@ const { serializeAMimeType } = require('./data-url') const { multipartFormDataParser } = require('./formdata-parser') const { parseJSONFromBytes } = require('../infra') const { utf8DecodeBytes } = require('../../encoding') -const { runtimeFeatures } = require('../../util/runtime-features.js') - -const random = runtimeFeatures.has('crypto') - ? require('node:crypto').randomInt - : (max) => Math.floor(Math.random() * max) const textEncoder = new TextEncoder() function noop () {} @@ -106,11 +101,7 @@ function extractBody (object, keepalive = false) { // Set source to a copy of the bytes held by object. source = webidl.util.getCopyOfBytesHeldByBufferSource(object) } else if (webidl.is.FormData(object)) { - let boundary = getFormDataBoundary(object) - if (boundary === null) { - boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}` - setFormDataBoundary(object, boundary) - } + const boundary = getFormDataBoundary(object) const prefix = `--${boundary}\r\nContent-Disposition: form-data` /*! formdata-polyfill. MIT License. Jimmy Wärting */ diff --git a/lib/web/fetch/formdata.js b/lib/web/fetch/formdata.js index 6dd828a898e..226bd329587 100644 --- a/lib/web/fetch/formdata.js +++ b/lib/web/fetch/formdata.js @@ -4,6 +4,11 @@ const { iteratorMixin } = require('./util') const { kEnumerableProperty } = require('../../core/util') const { webidl } = require('../webidl') const nodeUtil = require('node:util') +const { runtimeFeatures } = require('../../util/runtime-features.js') + +const random = runtimeFeatures.has('crypto') + ? require('node:crypto').randomInt + : (max) => Math.floor(Math.random() * max) // https://xhr.spec.whatwg.org/#formdata class FormData { @@ -199,23 +204,18 @@ class FormData { * @returns {string | null} */ static getFormDataBoundary (formData) { - return formData.#boundary - } + const boundary = formData.#boundary + if (boundary != null) return boundary - /** - * @param {FormData} formData - * @param {string} boundary - */ - static setFormDataBoundary (formData, boundary) { - formData.#boundary = boundary + // eslint-disable-next-line no-return-assign + return formData.#boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}` } } -const { getFormDataState, setFormDataState, getFormDataBoundary, setFormDataBoundary } = FormData +const { getFormDataState, setFormDataState, getFormDataBoundary } = FormData Reflect.deleteProperty(FormData, 'getFormDataState') Reflect.deleteProperty(FormData, 'setFormDataState') Reflect.deleteProperty(FormData, 'getFormDataBoundary') -Reflect.deleteProperty(FormData, 'setFormDataBoundary') iteratorMixin('FormData', FormData, getFormDataState, 'name', 'value') @@ -275,4 +275,4 @@ function makeEntry (name, value, filename) { webidl.is.FormData = webidl.util.MakeTypeAssertion(FormData) -module.exports = { FormData, makeEntry, setFormDataState, getFormDataBoundary, setFormDataBoundary } +module.exports = { FormData, makeEntry, setFormDataState, getFormDataBoundary }