From 8f5aeaef8ebb7bdb5b22b495d990679317420745 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 4 Jul 2026 11:01:53 +0000 Subject: [PATCH 1/4] fix: collapse identical repeated content-length in legacy dispatcher bridge Node's bundled fetch (undici v6/v7) appends its computed content-length even when the request already carries one, producing an identical comma-repeated value (e.g. "58, 58"). The v1 core tolerated this via parseInt, but the current core rejects it since #5060, so a bare require('undici') on Node 22/24 silently broke bundled-fetch requests that set their own Content-Length. Collapse identical repeated values (permitted by RFC 9110 section 8.6) in Dispatcher1Wrapper before dispatching; genuinely conflicting values are still rejected. Fixes #5500 --- lib/dispatcher/dispatcher1-wrapper.js | 58 ++++++++++ test/dispatcher1-wrapper-content-length.js | 120 ++++++++++++++++++++ test/node-test/global-dispatcher-version.js | 39 +++++++ 3 files changed, 217 insertions(+) create mode 100644 test/dispatcher1-wrapper-content-length.js diff --git a/lib/dispatcher/dispatcher1-wrapper.js b/lib/dispatcher/dispatcher1-wrapper.js index f5813288cb3..feaba1f9004 100644 --- a/lib/dispatcher/dispatcher1-wrapper.js +++ b/lib/dispatcher/dispatcher1-wrapper.js @@ -60,6 +60,59 @@ class LegacyHandlerWrapper { } } +// Legacy (v1) consumers such as Node's bundled fetch (undici v6/v7) append +// their computed content-length even when the request already carries one, +// producing an identical comma-repeated value (e.g. "58, 58") that the v1 +// core tolerated but the current core rejects. RFC 9110 permits treating +// identical repeated values as one, so collapse them; conflicting values +// are left untouched and still rejected downstream. +// See https://github.com/nodejs/undici/issues/5500 +function collapseRepeatedContentLength (value) { + if (typeof value !== 'string' || !value.includes(',')) { + return value + } + + const parts = value.split(',') + const first = parts[0].trim() + + if (first === '') { + return value + } + + for (let i = 1; i < parts.length; i++) { + if (parts[i].trim() !== first) { + return value + } + } + + return first +} + +function normalizeLegacyHeaders (headers) { + if (Array.isArray(headers)) { + for (let i = 0; i + 1 < headers.length; i += 2) { + if (typeof headers[i] === 'string' && headers[i].toLowerCase() === 'content-length') { + const collapsed = collapseRepeatedContentLength(headers[i + 1]) + if (collapsed !== headers[i + 1]) { + headers = headers.slice() + headers[i + 1] = collapsed + } + } + } + } else if (headers && typeof headers === 'object') { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'content-length') { + const collapsed = collapseRepeatedContentLength(headers[key]) + if (collapsed !== headers[key]) { + headers = { ...headers, [key]: collapsed } + } + } + } + } + + return headers +} + class Dispatcher1Wrapper extends Dispatcher { #dispatcher @@ -92,6 +145,11 @@ class Dispatcher1Wrapper extends Dispatcher { opts = { ...opts, allowH2: false } } + const headers = normalizeLegacyHeaders(opts.headers) + if (headers !== opts.headers) { + opts = { ...opts, headers } + } + return this.#dispatcher.dispatch(opts, Dispatcher1Wrapper.wrapHandler(handler)) } diff --git a/test/dispatcher1-wrapper-content-length.js b/test/dispatcher1-wrapper-content-length.js new file mode 100644 index 00000000000..9eaa9085870 --- /dev/null +++ b/test/dispatcher1-wrapper-content-length.js @@ -0,0 +1,120 @@ +'use strict' + +const { test, after } = require('node:test') +const assert = require('node:assert') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { Agent, Dispatcher1Wrapper } = require('..') + +// Legacy consumers such as Node's bundled fetch (undici v6/v7) can hand the +// dispatcher an identical comma-repeated content-length (e.g. "13, 13"). +// The wrapper must collapse it; conflicting values must still be rejected. +// See https://github.com/nodejs/undici/issues/5500 + +function legacyDispatch (wrapper, opts) { + return new Promise((resolve, reject) => { + let body = '' + wrapper.dispatch(opts, { + onConnect () {}, + onHeaders (statusCode) { + this.statusCode = statusCode + return true + }, + onData (chunk) { + body += chunk + return true + }, + onComplete () { + resolve({ statusCode: this.statusCode, body }) + }, + onError (err) { + reject(err) + } + }) + }) +} + +async function startServer (t) { + const server = createServer((req, res) => { + let length = 0 + req.on('data', (chunk) => { length += chunk.length }) + req.on('end', () => { + res.end(`${length}:${req.headers['content-length']}`) + }) + }) + server.listen(0) + await once(server, 'listening') + return server +} + +test('collapses identical repeated content-length from a legacy consumer', async (t) => { + const server = await startServer(t) + const agent = new Agent() + const wrapper = new Dispatcher1Wrapper(agent) + after(() => { server.close(); return agent.close() }) + + const { statusCode, body } = await legacyDispatch(wrapper, { + origin: `http://127.0.0.1:${server.address().port}`, + path: '/', + method: 'POST', + headers: { 'Content-Length': '13, 13', 'content-type': 'text/plain' }, + body: 'update=INSERT' + }) + + assert.strictEqual(statusCode, 200) + assert.strictEqual(body, '13:13') +}) + +test('collapses identical repeated content-length in flat array headers', async (t) => { + const server = await startServer(t) + const agent = new Agent() + const wrapper = new Dispatcher1Wrapper(agent) + after(() => { server.close(); return agent.close() }) + + const { statusCode, body } = await legacyDispatch(wrapper, { + origin: `http://127.0.0.1:${server.address().port}`, + path: '/', + method: 'POST', + headers: ['content-length', '13 , 13', 'content-type', 'text/plain'], + body: 'update=INSERT' + }) + + assert.strictEqual(statusCode, 200) + assert.strictEqual(body, '13:13') +}) + +test('still rejects conflicting repeated content-length', async (t) => { + const server = await startServer(t) + const agent = new Agent() + const wrapper = new Dispatcher1Wrapper(agent) + after(() => { server.close(); return agent.close() }) + + await assert.rejects( + legacyDispatch(wrapper, { + origin: `http://127.0.0.1:${server.address().port}`, + path: '/', + method: 'POST', + headers: { 'content-length': '10, 13' }, + body: 'update=INSERT' + }), + { code: 'UND_ERR_INVALID_ARG', message: 'invalid content-length header' } + ) +}) + +test('does not mutate the caller-provided headers object', async (t) => { + const server = await startServer(t) + const agent = new Agent() + const wrapper = new Dispatcher1Wrapper(agent) + after(() => { server.close(); return agent.close() }) + + const headers = { 'content-length': '13, 13' } + await legacyDispatch(wrapper, { + origin: `http://127.0.0.1:${server.address().port}`, + path: '/', + method: 'POST', + headers, + body: 'update=INSERT' + }) + + assert.strictEqual(headers['content-length'], '13, 13') +}) diff --git a/test/node-test/global-dispatcher-version.js b/test/node-test/global-dispatcher-version.js index 8fa688043e6..a14ecd1e90f 100644 --- a/test/node-test/global-dispatcher-version.js +++ b/test/node-test/global-dispatcher-version.js @@ -98,6 +98,45 @@ test('setGlobalDispatcher mirrors a v1-compatible dispatcher that Node.js global assert.strictEqual(payload.mirroredV2, true) }) +test('requiring undici does not break Node.js global fetch with a request-set Content-Length', () => { + // Node's bundled fetch (undici v6/v7) appends its computed content-length + // even when the request already carries one, producing "13, 13". + // See https://github.com/nodejs/undici/issues/5500 + const script = ` + require('./index.js') + const http = require('node:http') + const { once } = require('node:events') + + ;(async () => { + const server = http.createServer((req, res) => { + let length = 0 + req.on('data', (chunk) => { length += chunk.length }) + req.on('end', () => res.end(String(length))) + }) + server.listen(0) + await once(server, 'listening') + + const body = 'update=INSERT' + const headers = new Headers() + headers.append('Content-Type', 'application/x-www-form-urlencoded') + headers.append('Content-Length', String(body.length)) + + const url = 'http://127.0.0.1:' + server.address().port + const res = await fetch(url, { method: 'POST', headers, body }) + process.stdout.write(await res.text()) + + server.close() + })().catch((err) => { + console.error(err?.cause?.stack || err?.stack || err) + process.exit(1) + }) + ` + + const result = runNode(script) + assert.strictEqual(result.status, 0, result.stderr) + assert.strictEqual(result.stdout, '13') +}) + test('Dispatcher1Wrapper bridges legacy handlers to a new Agent', () => { const script = ` const { Agent, Dispatcher1Wrapper } = require('./index.js') From dded7eece1f66399b6722e49fc406a9e7e151d2f Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 4 Jul 2026 11:40:25 +0000 Subject: [PATCH 2/4] test: move bundled-fetch content-length regression script to a fixture --- test/fixtures/global-fetch-content-length.js | 43 ++++++++++++++++++++ test/node-test/global-dispatcher-version.js | 35 +++------------- 2 files changed, 48 insertions(+), 30 deletions(-) create mode 100644 test/fixtures/global-fetch-content-length.js diff --git a/test/fixtures/global-fetch-content-length.js b/test/fixtures/global-fetch-content-length.js new file mode 100644 index 00000000000..5e16b389b20 --- /dev/null +++ b/test/fixtures/global-fetch-content-length.js @@ -0,0 +1,43 @@ +'use strict' + +// This fixture must use Node's *global* fetch and Headers — exercising the +// bundled fetch against the installed undici's dispatcher bridge is the point. +/* eslint-disable no-restricted-globals */ + +// Regression fixture for https://github.com/nodejs/undici/issues/5500. +// Requiring undici installs the legacy global-dispatcher bridge; Node's +// bundled fetch (undici v6/v7) then dispatches through it and produces an +// identical comma-repeated content-length (e.g. "13, 13") when the request +// sets its own Content-Length header. +require('../..') + +const http = require('node:http') +const { once } = require('node:events') + +async function main () { + const server = http.createServer((req, res) => { + let length = 0 + req.on('data', (chunk) => { length += chunk.length }) + req.on('end', () => res.end(String(length))) + }) + server.listen(0) + await once(server, 'listening') + + try { + const body = 'update=INSERT' + const headers = new Headers() + headers.append('Content-Type', 'application/x-www-form-urlencoded') + headers.append('Content-Length', String(body.length)) + + const url = 'http://127.0.0.1:' + server.address().port + const res = await fetch(url, { method: 'POST', headers, body }) + process.stdout.write(await res.text()) + } finally { + server.close() + } +} + +main().catch((err) => { + console.error(err?.cause?.stack || err?.stack || err) + process.exitCode = 1 +}) diff --git a/test/node-test/global-dispatcher-version.js b/test/node-test/global-dispatcher-version.js index a14ecd1e90f..65bac6eed6d 100644 --- a/test/node-test/global-dispatcher-version.js +++ b/test/node-test/global-dispatcher-version.js @@ -102,37 +102,12 @@ test('requiring undici does not break Node.js global fetch with a request-set Co // Node's bundled fetch (undici v6/v7) appends its computed content-length // even when the request already carries one, producing "13, 13". // See https://github.com/nodejs/undici/issues/5500 - const script = ` - require('./index.js') - const http = require('node:http') - const { once } = require('node:events') - - ;(async () => { - const server = http.createServer((req, res) => { - let length = 0 - req.on('data', (chunk) => { length += chunk.length }) - req.on('end', () => res.end(String(length))) - }) - server.listen(0) - await once(server, 'listening') - - const body = 'update=INSERT' - const headers = new Headers() - headers.append('Content-Type', 'application/x-www-form-urlencoded') - headers.append('Content-Length', String(body.length)) - - const url = 'http://127.0.0.1:' + server.address().port - const res = await fetch(url, { method: 'POST', headers, body }) - process.stdout.write(await res.text()) - - server.close() - })().catch((err) => { - console.error(err?.cause?.stack || err?.stack || err) - process.exit(1) - }) - ` + const result = spawnSync( + process.execPath, + [join(__dirname, '../fixtures/global-fetch-content-length.js')], + { cwd, encoding: 'utf8' } + ) - const result = runNode(script) assert.strictEqual(result.status, 0, result.stderr) assert.strictEqual(result.stdout, '13') }) From 0bba6d79f09c0bf38d8377d52729f18a07c9b50b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 4 Jul 2026 14:45:56 +0000 Subject: [PATCH 3/4] chore: shorten comments --- lib/dispatcher/dispatcher1-wrapper.js | 11 ++++------- test/dispatcher1-wrapper-content-length.js | 3 --- test/fixtures/global-fetch-content-length.js | 9 ++------- test/node-test/global-dispatcher-version.js | 2 -- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/lib/dispatcher/dispatcher1-wrapper.js b/lib/dispatcher/dispatcher1-wrapper.js index feaba1f9004..7795e314673 100644 --- a/lib/dispatcher/dispatcher1-wrapper.js +++ b/lib/dispatcher/dispatcher1-wrapper.js @@ -60,13 +60,10 @@ class LegacyHandlerWrapper { } } -// Legacy (v1) consumers such as Node's bundled fetch (undici v6/v7) append -// their computed content-length even when the request already carries one, -// producing an identical comma-repeated value (e.g. "58, 58") that the v1 -// core tolerated but the current core rejects. RFC 9110 permits treating -// identical repeated values as one, so collapse them; conflicting values -// are left untouched and still rejected downstream. -// See https://github.com/nodejs/undici/issues/5500 +// Legacy consumers (e.g. Node's bundled fetch) may send an identical +// comma-repeated content-length ("58, 58") that the current core rejects. +// RFC 9110 allows collapsing identical repeats; conflicting values still +// fail downstream. See https://github.com/nodejs/undici/issues/5500 function collapseRepeatedContentLength (value) { if (typeof value !== 'string' || !value.includes(',')) { return value diff --git a/test/dispatcher1-wrapper-content-length.js b/test/dispatcher1-wrapper-content-length.js index 9eaa9085870..bec910e99ac 100644 --- a/test/dispatcher1-wrapper-content-length.js +++ b/test/dispatcher1-wrapper-content-length.js @@ -6,9 +6,6 @@ const { createServer } = require('node:http') const { once } = require('node:events') const { Agent, Dispatcher1Wrapper } = require('..') -// Legacy consumers such as Node's bundled fetch (undici v6/v7) can hand the -// dispatcher an identical comma-repeated content-length (e.g. "13, 13"). -// The wrapper must collapse it; conflicting values must still be rejected. // See https://github.com/nodejs/undici/issues/5500 function legacyDispatch (wrapper, opts) { diff --git a/test/fixtures/global-fetch-content-length.js b/test/fixtures/global-fetch-content-length.js index 5e16b389b20..42e030bcf78 100644 --- a/test/fixtures/global-fetch-content-length.js +++ b/test/fixtures/global-fetch-content-length.js @@ -1,14 +1,9 @@ 'use strict' -// This fixture must use Node's *global* fetch and Headers — exercising the -// bundled fetch against the installed undici's dispatcher bridge is the point. +// Exercising Node's *global* fetch/Headers against the dispatcher bridge +// is the point. See https://github.com/nodejs/undici/issues/5500 /* eslint-disable no-restricted-globals */ -// Regression fixture for https://github.com/nodejs/undici/issues/5500. -// Requiring undici installs the legacy global-dispatcher bridge; Node's -// bundled fetch (undici v6/v7) then dispatches through it and produces an -// identical comma-repeated content-length (e.g. "13, 13") when the request -// sets its own Content-Length header. require('../..') const http = require('node:http') diff --git a/test/node-test/global-dispatcher-version.js b/test/node-test/global-dispatcher-version.js index 65bac6eed6d..bfbfa24f05e 100644 --- a/test/node-test/global-dispatcher-version.js +++ b/test/node-test/global-dispatcher-version.js @@ -99,8 +99,6 @@ test('setGlobalDispatcher mirrors a v1-compatible dispatcher that Node.js global }) test('requiring undici does not break Node.js global fetch with a request-set Content-Length', () => { - // Node's bundled fetch (undici v6/v7) appends its computed content-length - // even when the request already carries one, producing "13, 13". // See https://github.com/nodejs/undici/issues/5500 const result = spawnSync( process.execPath, From bfad27df175f4222595337f02a5e70faae2924d1 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 6 Jul 2026 11:34:09 +0000 Subject: [PATCH 4/4] test: use t.after and cover collapse edge cases --- test/dispatcher1-wrapper-content-length.js | 64 ++++++++++++++-------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/test/dispatcher1-wrapper-content-length.js b/test/dispatcher1-wrapper-content-length.js index bec910e99ac..5b999b23fd6 100644 --- a/test/dispatcher1-wrapper-content-length.js +++ b/test/dispatcher1-wrapper-content-length.js @@ -1,6 +1,6 @@ 'use strict' -const { test, after } = require('node:test') +const { test } = require('node:test') const assert = require('node:assert') const { createServer } = require('node:http') const { once } = require('node:events') @@ -31,7 +31,7 @@ function legacyDispatch (wrapper, opts) { }) } -async function startServer (t) { +async function startServer () { const server = createServer((req, res) => { let length = 0 req.on('data', (chunk) => { length += chunk.length }) @@ -45,10 +45,10 @@ async function startServer (t) { } test('collapses identical repeated content-length from a legacy consumer', async (t) => { - const server = await startServer(t) + const server = await startServer() const agent = new Agent() const wrapper = new Dispatcher1Wrapper(agent) - after(() => { server.close(); return agent.close() }) + t.after(() => { server.close(); return agent.close() }) const { statusCode, body } = await legacyDispatch(wrapper, { origin: `http://127.0.0.1:${server.address().port}`, @@ -63,10 +63,10 @@ test('collapses identical repeated content-length from a legacy consumer', async }) test('collapses identical repeated content-length in flat array headers', async (t) => { - const server = await startServer(t) + const server = await startServer() const agent = new Agent() const wrapper = new Dispatcher1Wrapper(agent) - after(() => { server.close(); return agent.close() }) + t.after(() => { server.close(); return agent.close() }) const { statusCode, body } = await legacyDispatch(wrapper, { origin: `http://127.0.0.1:${server.address().port}`, @@ -80,29 +80,49 @@ test('collapses identical repeated content-length in flat array headers', async assert.strictEqual(body, '13:13') }) -test('still rejects conflicting repeated content-length', async (t) => { - const server = await startServer(t) +test('leaves a single content-length untouched', async (t) => { + const server = await startServer() const agent = new Agent() const wrapper = new Dispatcher1Wrapper(agent) - after(() => { server.close(); return agent.close() }) - - await assert.rejects( - legacyDispatch(wrapper, { - origin: `http://127.0.0.1:${server.address().port}`, - path: '/', - method: 'POST', - headers: { 'content-length': '10, 13' }, - body: 'update=INSERT' - }), - { code: 'UND_ERR_INVALID_ARG', message: 'invalid content-length header' } - ) + t.after(() => { server.close(); return agent.close() }) + + const { statusCode, body } = await legacyDispatch(wrapper, { + origin: `http://127.0.0.1:${server.address().port}`, + path: '/', + method: 'POST', + headers: { 'content-length': '13' }, + body: 'update=INSERT' + }) + + assert.strictEqual(statusCode, 200) + assert.strictEqual(body, '13:13') +}) + +test('still rejects conflicting or malformed repeated content-length', async (t) => { + const server = await startServer() + const agent = new Agent() + const wrapper = new Dispatcher1Wrapper(agent) + t.after(() => { server.close(); return agent.close() }) + + for (const value of ['10, 13', ', 13']) { + await assert.rejects( + legacyDispatch(wrapper, { + origin: `http://127.0.0.1:${server.address().port}`, + path: '/', + method: 'POST', + headers: { 'content-length': value }, + body: 'update=INSERT' + }), + { code: 'UND_ERR_INVALID_ARG', message: 'invalid content-length header' } + ) + } }) test('does not mutate the caller-provided headers object', async (t) => { - const server = await startServer(t) + const server = await startServer() const agent = new Agent() const wrapper = new Dispatcher1Wrapper(agent) - after(() => { server.close(); return agent.close() }) + t.after(() => { server.close(); return agent.close() }) const headers = { 'content-length': '13, 13' } await legacyDispatch(wrapper, {