Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions lib/core/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[]>} 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<string, string | string[]>} [obj]
Expand Down Expand Up @@ -1022,6 +1046,7 @@ module.exports = {
toRawHeaders,
encodeRawHeaders,
parseHeaders,
setHeader,
parseKeepAliveTimeout,
destroy,
bodyLength,
Expand Down
12 changes: 7 additions & 5 deletions lib/dispatcher/client-h2.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}

Expand Down
5 changes: 3 additions & 2 deletions lib/mock/snapshot-recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

/**
Expand Down Expand Up @@ -169,7 +170,7 @@ function filterHeadersForMatching (headers, headerFilters, matchOptions = {}) {
if (!match.has(headerKey)) continue
}

filtered[headerKey] = value
setHeader(filtered, headerKey, value)
}

return filtered
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/mock/snapshot-utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

const { InvalidArgumentError } = require('../core/errors')
const { setHeader } = require('../core/util')
const { runtimeFeatures } = require('../util/runtime-features.js')

/**
Expand Down Expand Up @@ -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
Expand All @@ -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))
}
}
}
Expand Down
16 changes: 11 additions & 5 deletions lib/util/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ const {
safeHTTPMethods,
pathHasQueryOrFragment,
hasSafeIterator,
isValidHTTPToken
isValidHTTPToken,
setHeader
} = require('../core/util')

const { serializePathWithQuery } = require('../core/util')
Expand Down Expand Up @@ -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])
}
}

Expand Down Expand Up @@ -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])
}
}

Expand Down
4 changes: 2 additions & 2 deletions lib/web/fetch/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}

Expand Down
17 changes: 17 additions & 0 deletions test/cache-interceptor/cache-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
20 changes: 20 additions & 0 deletions test/interceptors/deduplicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
84 changes: 84 additions & 0 deletions test/prototype-headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
})
28 changes: 28 additions & 0 deletions test/snapshot-recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
})
Loading