From 03383579be20dc6acf237e5bb4565122253a16f6 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 27 Jan 2026 00:13:11 +0100 Subject: [PATCH 1/3] fix: support Connection header with connection-specific header names per RFC 7230 Per RFC 7230 Section 6.1, the Connection header can contain a comma-separated list of connection option tokens (header names) that should be removed by proxies before forwarding the message. Previously, undici only allowed 'close' or 'keep-alive' as Connection header values. This change allows any valid HTTP token as a connection option, enabling RFC-compliant requests like: Connection: X-Custom-Header Connection: close, X-Custom-Header Fixes: https://github.com/nodejs/undici/issues/4774 Signed-off-by: Matteo Collina --- lib/core/request.js | 15 +++++-- test/request.js | 97 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/lib/core/request.js b/lib/core/request.js index 7dbf781b4c4..3eda32da5c8 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -394,12 +394,21 @@ function processHeader (request, key, val) { } else if (headerName === 'transfer-encoding' || headerName === 'keep-alive' || headerName === 'upgrade') { throw new InvalidArgumentError(`invalid ${headerName} header`) } else if (headerName === 'connection') { - const value = typeof val === 'string' ? val.toLowerCase() : null - if (value !== 'close' && value !== 'keep-alive') { + // Per RFC 7230 Section 6.1, Connection header can contain + // a comma-separated list of connection option tokens (header names) + const value = typeof val === 'string' ? val : null + if (value === null) { throw new InvalidArgumentError('invalid connection header') } - if (value === 'close') { + const tokens = value.split(',').map(t => t.trim().toLowerCase()) + for (const token of tokens) { + if (!isValidHTTPToken(token)) { + throw new InvalidArgumentError('invalid connection header') + } + } + + if (tokens.includes('close')) { request.reset = true } } else if (headerName === 'expect') { diff --git a/test/request.js b/test/request.js index 6166a95e079..fd77c61e483 100644 --- a/test/request.js +++ b/test/request.js @@ -468,3 +468,100 @@ describe('Should include headers from iterable objects', scope => { }) }) }) + +describe('connection header per RFC 7230', () => { + test('should allow close', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('ok') + }) + + after(() => server.close()) + await new Promise((resolve) => server.listen(0, resolve)) + + const { statusCode, body } = await request({ + method: 'GET', + origin: `http://localhost:${server.address().port}`, + headers: { connection: 'close' } + }) + await body.dump() + t.strictEqual(statusCode, 200) + }) + + test('should allow keep-alive', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('ok') + }) + + after(() => server.close()) + await new Promise((resolve) => server.listen(0, resolve)) + + const { statusCode, body } = await request({ + method: 'GET', + origin: `http://localhost:${server.address().port}`, + headers: { connection: 'keep-alive' } + }) + await body.dump() + t.strictEqual(statusCode, 200) + }) + + test('should allow custom header name as connection option', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('ok') + }) + + after(() => server.close()) + await new Promise((resolve) => server.listen(0, resolve)) + + const { statusCode, body } = await request({ + method: 'GET', + origin: `http://localhost:${server.address().port}`, + headers: { + 'x-custom-header': 'value', + connection: 'x-custom-header' + } + }) + await body.dump() + t.strictEqual(statusCode, 200) + }) + + test('should allow comma-separated list of connection options', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('ok') + }) + + after(() => server.close()) + await new Promise((resolve) => server.listen(0, resolve)) + + const { statusCode, body } = await request({ + method: 'GET', + origin: `http://localhost:${server.address().port}`, + headers: { + 'x-custom-header': 'value', + connection: 'close, x-custom-header' + } + }) + await body.dump() + t.strictEqual(statusCode, 200) + }) + + test('should reject invalid tokens in connection header', async (t) => { + t = tspl(t, { plan: 2 }) + + await request({ + method: 'GET', + origin: 'http://localhost:1234', + headers: { connection: 'invalid header with spaces' } + }).catch((err) => { + t.ok(err instanceof errors.InvalidArgumentError) + t.strictEqual(err.message, 'invalid connection header') + }) + }) +}) From bc1c6d6f6c788ccbbb9898ae00841f4d9a8255be Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 27 Jan 2026 20:12:55 +0100 Subject: [PATCH 2/3] refactor: optimize connection header parsing to single pass - Lowercase before split as suggested - Combine validation and close-check in single loop Signed-off-by: Matteo Collina --- lib/core/request.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/core/request.js b/lib/core/request.js index 3eda32da5c8..614922f7efa 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -401,15 +401,14 @@ function processHeader (request, key, val) { throw new InvalidArgumentError('invalid connection header') } - const tokens = value.split(',').map(t => t.trim().toLowerCase()) - for (const token of tokens) { - if (!isValidHTTPToken(token)) { + for (const token of value.toLowerCase().split(',')) { + const trimmed = token.trim() + if (!isValidHTTPToken(trimmed)) { throw new InvalidArgumentError('invalid connection header') } - } - - if (tokens.includes('close')) { - request.reset = true + if (trimmed === 'close') { + request.reset = true + } } } else if (headerName === 'expect') { throw new NotSupportedError('expect header not supported') From 8de106e6c41a8c589f7c280f9fd709c22571bc41 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 28 Jan 2026 18:02:28 +0100 Subject: [PATCH 3/3] test: update invalid connection header test for RFC 7230 compliance The previous test used 'asd' as an invalid connection header value, but 'asd' is a valid HTTP token per RFC 7230. Updated to use a truly invalid value (token with spaces) instead. Signed-off-by: Matteo Collina --- test/invalid-headers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/invalid-headers.js b/test/invalid-headers.js index 5d49fcbf23f..c7801949343 100644 --- a/test/invalid-headers.js +++ b/test/invalid-headers.js @@ -51,7 +51,7 @@ test('invalid headers', (t) => { path: '/', method: 'GET', headers: { - connection: 'asd' + connection: 'invalid header with spaces' } }, (err, data) => { t.ok(err instanceof errors.InvalidArgumentError)