From 65fcb98b7ad964d6a057202f5d7ee41c24b208af Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 23 Mar 2026 10:36:38 +0100 Subject: [PATCH 1/2] fix: handle prototype-shadowing response headers Signed-off-by: Matteo Collina --- lib/core/util.js | 20 +++++++--- test/prototype-headers.js | 84 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 test/prototype-headers.js diff --git a/lib/core/util.js b/lib/core/util.js index db8dda53a81..4b23b78be57 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -440,18 +440,28 @@ function parseHeaders (headers, obj) { const key = headerNameToString(headers[i]) let val = obj[key] - if (val) { + if (Object.hasOwn(obj, key)) { if (typeof val === 'string') { val = [val] obj[key] = val } val.push(headers[i + 1].toString('latin1')) } else { - const headersValue = headers[i + 1] - if (typeof headersValue === 'string') { - obj[key] = headersValue + const headersValue = typeof headers[i + 1] === 'string' + ? headers[i + 1] + : Array.isArray(headers[i + 1]) + ? headers[i + 1].map(x => x.toString('latin1')) + : headers[i + 1].toString('latin1') + + if (key === '__proto__') { + Object.defineProperty(obj, key, { + value: headersValue, + enumerable: true, + configurable: true, + writable: true + }) } else { - obj[key] = Array.isArray(headersValue) ? headersValue.map(x => x.toString('latin1')) : headersValue.toString('latin1') + obj[key] = headersValue } } } diff --git a/test/prototype-headers.js b/test/prototype-headers.js new file mode 100644 index 00000000000..aa1bf15be26 --- /dev/null +++ b/test/prototype-headers.js @@ -0,0 +1,84 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { promisify } = require('node:util') +const net = require('node:net') +const { Client } = require('..') + +function createRawServer (response) { + return net.createServer((socket) => { + socket.once('data', () => { + socket.end(response) + }) + }) +} + +test('request handles response headers that shadow Object.prototype', async (t) => { + const server = createRawServer([ + 'HTTP/1.1 200 OK', + '__proto__: pwned', + 'constructor: built-in', + 'content-length: 2', + 'connection: close', + '', + 'OK' + ].join('\r\n')) + + t.after(() => { + server.closeAllConnections?.() + server.close() + }) + + await promisify(server.listen.bind(server))(0) + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => client.close()) + + const { statusCode, headers, body } = await client.request({ + path: '/', + method: 'GET' + }) + + assert.strictEqual(statusCode, 200) + assert.strictEqual(Object.getOwnPropertyDescriptor(headers, '__proto__').value, 'pwned') + assert.strictEqual(Object.getOwnPropertyDescriptor(headers, 'constructor').value, 'built-in') + assert.strictEqual(await body.text(), 'OK') +}) + +test('request handles response trailers that shadow Object.prototype', async (t) => { + const server = createRawServer([ + 'HTTP/1.1 200 OK', + 'transfer-encoding: chunked', + 'trailer: __proto__, constructor', + 'connection: close', + '', + '2', + 'OK', + '0', + '__proto__: trailer', + 'constructor: built-in-trailer', + '', + '' + ].join('\r\n')) + + t.after(() => { + server.closeAllConnections?.() + server.close() + }) + + await promisify(server.listen.bind(server))(0) + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => client.close()) + + const { statusCode, trailers, body } = await client.request({ + path: '/', + method: 'GET' + }) + + assert.strictEqual(statusCode, 200) + assert.strictEqual(await body.text(), 'OK') + assert.strictEqual(Object.getOwnPropertyDescriptor(trailers, '__proto__').value, 'trailer') + assert.strictEqual(Object.getOwnPropertyDescriptor(trailers, 'constructor').value, 'built-in-trailer') +}) From 108e9394f0f0ff0560e4842e5a7e2de85473536c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 23 Mar 2026 11:37:00 +0100 Subject: [PATCH 2/2] perf: reduce parseHeaders overhead Signed-off-by: Matteo Collina --- benchmarks/core/parse-headers.mjs | 2 +- lib/core/util.js | 40 +++++++++++++++++++------------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/benchmarks/core/parse-headers.mjs b/benchmarks/core/parse-headers.mjs index 439986da93d..a005b3b33b2 100644 --- a/benchmarks/core/parse-headers.mjs +++ b/benchmarks/core/parse-headers.mjs @@ -87,7 +87,7 @@ bench('noop', () => {}) bench('noop', () => {}) bench('noop', () => {}) -group('parseHeaders', () => { +group(() => { bench('parseHeaders', () => { for (let i = 0; i < headers.length; ++i) { parseHeaders(headers[i]) diff --git a/lib/core/util.js b/lib/core/util.js index 4b23b78be57..767d586b93a 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -440,12 +440,31 @@ function parseHeaders (headers, obj) { const key = headerNameToString(headers[i]) let val = obj[key] - if (Object.hasOwn(obj, key)) { - if (typeof val === 'string') { - val = [val] - obj[key] = val + if (val !== undefined) { + if (!Object.hasOwn(obj, key)) { + const headersValue = typeof headers[i + 1] === 'string' + ? headers[i + 1] + : Array.isArray(headers[i + 1]) + ? headers[i + 1].map(x => x.toString('latin1')) + : headers[i + 1].toString('latin1') + + if (key === '__proto__') { + Object.defineProperty(obj, key, { + value: headersValue, + enumerable: true, + configurable: true, + writable: true + }) + } else { + obj[key] = headersValue + } + } else { + if (typeof val === 'string') { + val = [val] + obj[key] = val + } + val.push(headers[i + 1].toString('latin1')) } - val.push(headers[i + 1].toString('latin1')) } else { const headersValue = typeof headers[i + 1] === 'string' ? headers[i + 1] @@ -453,16 +472,7 @@ function parseHeaders (headers, obj) { ? headers[i + 1].map(x => x.toString('latin1')) : headers[i + 1].toString('latin1') - if (key === '__proto__') { - Object.defineProperty(obj, key, { - value: headersValue, - enumerable: true, - configurable: true, - writable: true - }) - } else { - obj[key] = headersValue - } + obj[key] = headersValue } }