diff --git a/lib/core/util.js b/lib/core/util.js index f00ddfce0d1..6d0409f87c8 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -417,6 +417,30 @@ function bufferToLowerCasedHeaderName (value) { return tree.lookup(value) ?? value.toString('latin1').toLowerCase() } +/** + * Writes a header onto a plain object without going through the + * `Object.prototype` `__proto__` setter. Plain assignment there drops a string + * value and, when the value is an array, replaces the object's prototype + * instead of storing anything. Every other name is a normal assignment. + * + * @param {Record} obj Accumulator to write onto + * @param {string} key Header name + * @param {string | string[]} value Header value + * @returns {void} + */ +function setHeader (obj, key, value) { + if (key === '__proto__') { + Object.defineProperty(obj, key, { + value, + enumerable: true, + configurable: true, + writable: true + }) + } else { + obj[key] = value + } +} + /** * @param {(Buffer | string)[]} headers * @param {Record} [obj] @@ -1022,6 +1046,7 @@ module.exports = { toRawHeaders, encodeRawHeaders, parseHeaders, + setHeader, parseKeepAliveTimeout, destroy, bodyLength, diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index bc401008f00..564c33a842d 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -830,25 +830,27 @@ function buildRequestHeaders (reqHeaders) { for (let n = 0; n < reqHeaders.length; n += 2) { const key = reqHeaders[n + 0] const val = reqHeaders[n + 1] - const current = headers[key] + // Reading headers[key] directly resolves `__proto__` through the prototype + // chain instead of reporting the header as absent. + const current = Object.hasOwn(headers, key) ? headers[key] : undefined if (key === 'cookie') { if (current != null) { - headers[key] = Array.isArray(current) ? (current.push(val), current) : [current, val] + util.setHeader(headers, key, Array.isArray(current) ? (current.push(val), current) : [current, val]) } else { - headers[key] = val + util.setHeader(headers, key, val) } continue } if (typeof val === 'string') { - headers[key] = current ? `${current}, ${val}` : val + util.setHeader(headers, key, current ? `${current}, ${val}` : val) continue } for (let i = 0; i < val.length; i++) { - headers[key] = headers[key] ? `${headers[key]}, ${val[i]}` : val[i] + util.setHeader(headers, key, Object.hasOwn(headers, key) ? `${headers[key]}, ${val[i]}` : val[i]) } } diff --git a/lib/mock/snapshot-recorder.js b/lib/mock/snapshot-recorder.js index 57a7e7a41dd..e69bcc5a777 100644 --- a/lib/mock/snapshot-recorder.js +++ b/lib/mock/snapshot-recorder.js @@ -4,6 +4,7 @@ const { writeFile, readFile, mkdir } = require('node:fs/promises') const { dirname, resolve } = require('node:path') const { setTimeout, clearTimeout } = require('node:timers') const { InvalidArgumentError, UndiciError } = require('../core/errors') +const { setHeader } = require('../core/util') const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require('./snapshot-utils') /** @@ -169,7 +170,7 @@ function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) { if (!match.has(headerKey)) continue } - filtered[headerKey] = value + setHeader(filtered, headerKey, value) } return filtered @@ -198,7 +199,7 @@ function filterHeadersForStorage (headers, headerFilters, matchOptions = {}) { // Skip if in exclude list (for security) if (excludeSet.has(headerKey)) continue - filtered[headerKey] = value + setHeader(filtered, headerKey, value) } return filtered diff --git a/lib/mock/snapshot-utils.js b/lib/mock/snapshot-utils.js index a14b69c15d4..c40f2f1caa2 100644 --- a/lib/mock/snapshot-utils.js +++ b/lib/mock/snapshot-utils.js @@ -1,6 +1,7 @@ 'use strict' const { InvalidArgumentError } = require('../core/errors') +const { setHeader } = require('../core/util') const { runtimeFeatures } = require('../util/runtime-features.js') /** @@ -116,7 +117,7 @@ function normalizeHeaders (headers) { // Convert Buffers to strings if needed const keyStr = Buffer.isBuffer(key) ? key.toString() : key const valueStr = Buffer.isBuffer(value) ? value.toString() : value - normalizedHeaders[keyStr.toLowerCase()] = valueStr + setHeader(normalizedHeaders, keyStr.toLowerCase(), valueStr) } } return normalizedHeaders @@ -126,7 +127,7 @@ function normalizeHeaders (headers) { if (headers && typeof headers === 'object') { for (const [key, value] of Object.entries(headers)) { if (key && typeof key === 'string') { - normalizedHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value) + setHeader(normalizedHeaders, key.toLowerCase(), Array.isArray(value) ? value.join(', ') : String(value)) } } } diff --git a/lib/util/cache.js b/lib/util/cache.js index 1fac28af5d9..c658ef817e9 100644 --- a/lib/util/cache.js +++ b/lib/util/cache.js @@ -4,7 +4,8 @@ const { safeHTTPMethods, pathHasQueryOrFragment, hasSafeIterator, - isValidHTTPToken + isValidHTTPToken, + setHeader } = require('../core/util') const { serializePathWithQuery } = require('../core/util') @@ -166,15 +167,20 @@ function makeCacheKey (opts) { function appendHeader (headers, key, val) { const headerName = key.toLowerCase() - const current = headers[headerName] + // Reading `headers[headerName]` directly would resolve `__proto__` (and any + // other inherited name) through the prototype chain rather than report the + // header as absent. + const current = Object.hasOwn(headers, headerName) + ? headers[headerName] + : undefined const values = Array.isArray(val) ? val : [val] if (current === undefined) { - headers[headerName] = Array.isArray(val) ? val.slice() : val + setHeader(headers, headerName, Array.isArray(val) ? val.slice() : val) } else if (Array.isArray(current)) { current.push(...values) } else { - headers[headerName] = [current, ...values] + setHeader(headers, headerName, [current, ...values]) } } @@ -692,7 +698,7 @@ function makeDeduplicationKey (cacheKey, excludeHeaders) { if (excludeHeaders?.has(header.toLowerCase())) { continue } - headers[header] = cacheKey.headers[header] + setHeader(headers, header, cacheKey.headers[header]) } } diff --git a/lib/web/fetch/headers.js b/lib/web/fetch/headers.js index 024d1989588..41e0e65c79d 100644 --- a/lib/web/fetch/headers.js +++ b/lib/web/fetch/headers.js @@ -3,7 +3,7 @@ 'use strict' const { kConstruct } = require('../../core/symbols') -const { kEnumerableProperty } = require('../../core/util') +const { kEnumerableProperty, setHeader } = require('../../core/util') const { iteratorMixin, isValidHeaderName, @@ -320,7 +320,7 @@ class HeadersList { if (this.headersMap.size !== 0) { for (const { name, value } of this.headersMap.values()) { - headers[name] = value + setHeader(headers, name, value) } } diff --git a/test/cache-interceptor/cache-utils.js b/test/cache-interceptor/cache-utils.js index fed0586668e..4f8b271346d 100644 --- a/test/cache-interceptor/cache-utils.js +++ b/test/cache-interceptor/cache-utils.js @@ -138,3 +138,20 @@ test('normalizeHeaders throws on non-string key in flat array', (t) => { message: 'opts.headers is not a valid header map' }) }) + +test('normalizeHeaders keeps a header named __proto__ without reprototyping the result', (t) => { + const { strictEqual, deepStrictEqual } = tspl(t, { plan: 5 }) + + // JSON.parse is the everyday way an own `__proto__` key shows up, e.g. + // headers read from a config file or a request body. + const headers = normalizeHeaders({ + headers: JSON.parse('{"__proto__":"a","x-real":"same"}') + }) + + deepStrictEqual(Object.keys(headers).sort(), ['__proto__', 'x-real']) + strictEqual(Object.getOwnPropertyDescriptor(headers, '__proto__').value, 'a') + strictEqual(headers['x-real'], 'same') + strictEqual(Object.getPrototypeOf(headers), Object.prototype) + // A header named `length` must not resolve through an array prototype. + strictEqual(headers.length, undefined) +}) diff --git a/test/interceptors/deduplicate.js b/test/interceptors/deduplicate.js index 0092c3029c0..c5054bd21eb 100644 --- a/test/interceptors/deduplicate.js +++ b/test/interceptors/deduplicate.js @@ -1338,6 +1338,26 @@ describe('Deduplicate Interceptor', () => { notStrictEqual(key1, key2) }) + test('makeDeduplicationKey does not collide on a header named __proto__', () => { + const withProto = JSON.parse('{"__proto__":"a","x-real":"same"}') + + const key1 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: withProto + }) + + const key2 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: { 'x-real': 'same' } + }) + + notStrictEqual(key1, key2) + }) + test('makeDeduplicationKey produces same key for identical headers', () => { const key1 = makeDeduplicationKey({ origin: 'https://example.com', diff --git a/test/prototype-headers.js b/test/prototype-headers.js index aa1bf15be26..74922c18084 100644 --- a/test/prototype-headers.js +++ b/test/prototype-headers.js @@ -82,3 +82,87 @@ test('request handles response trailers that shadow Object.prototype', async (t) assert.strictEqual(Object.getOwnPropertyDescriptor(trailers, '__proto__').value, 'trailer') assert.strictEqual(Object.getOwnPropertyDescriptor(trailers, 'constructor').value, 'built-in-trailer') }) + +test('fetch sends a request header named __proto__', async (t) => { + const { createServer } = require('node:http') + const { fetch, Headers } = require('..') + + let rawHeaders = null + const server = createServer((req, res) => { + rawHeaders = req.rawHeaders + res.end('OK') + }) + + t.after(() => { + server.closeAllConnections?.() + server.close() + }) + + await promisify(server.listen.bind(server))(0) + + const headers = new Headers() + headers.set('__proto__', 'pwned') + headers.set('x-control', 'sent') + + const response = await fetch(`http://localhost:${server.address().port}/`, { + headers + }) + await response.text() + + const received = {} + for (let i = 0; i < rawHeaders.length; i += 2) { + Object.defineProperty(received, rawHeaders[i].toLowerCase(), { + configurable: true, + enumerable: true, + value: rawHeaders[i + 1], + writable: true + }) + } + + assert.strictEqual( + Object.getOwnPropertyDescriptor(received, 'x-control').value, + 'sent' + ) + assert.strictEqual( + Object.getOwnPropertyDescriptor(received, '__proto__')?.value, + 'pwned' + ) +}) + +test('h2 sends a request header named __proto__', async (t) => { + const { createSecureServer } = require('node:http2') + const { once } = require('node:events') + const pem = require('@metcoder95/https-pem') + + let received = null + const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } })) + + server.on('stream', (stream, headers) => { + received = headers + stream.respond({ ':status': 200 }) + stream.end('OK') + }) + + t.after(() => server.close()) + await once(server.listen(0), 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + allowH2: true, + connect: { rejectUnauthorized: false } + }) + t.after(() => client.close()) + + const { body } = await client.request({ + path: '/', + method: 'GET', + // The flat array form is what buildRequestHeaders receives. + headers: ['__proto__', 'pwned', 'x-control', 'sent'] + }) + await body.text() + + assert.strictEqual(received['x-control'], 'sent') + assert.strictEqual( + Object.getOwnPropertyDescriptor(received, '__proto__')?.value, + 'pwned' + ) +}) diff --git a/test/snapshot-recorder.js b/test/snapshot-recorder.js index 55fa38c747e..469213be567 100644 --- a/test/snapshot-recorder.js +++ b/test/snapshot-recorder.js @@ -422,3 +422,31 @@ test('SnapshotRecorder - redirect responses are stored correctly', (t) => { assert.strictEqual(snapshot.responses[0].statusCode, 302, 'First response should be redirect') assert.strictEqual(snapshot.responses[1].statusCode, 200, 'Second response should be final') }) + +test('SnapshotRecorder - a header named __proto__ does not collide with its absence', (t) => { + const filters = createHeaderFilters({}) + + // JSON.parse is the everyday way an own `__proto__` key appears. + const withProto = formatRequestKey({ + origin: 'https://example.com', + path: '/resource', + method: 'GET', + headers: JSON.parse('{"__proto__":"a","x-real":"same"}') + }, filters) + + const withoutProto = formatRequestKey({ + origin: 'https://example.com', + path: '/resource', + method: 'GET', + headers: { 'x-real': 'same' } + }, filters) + + assert.strictEqual( + Object.getOwnPropertyDescriptor(withProto.headers, '__proto__')?.value, + 'a' + ) + assert.notStrictEqual( + createRequestHash(withProto), + createRequestHash(withoutProto) + ) +})