Skip to content

fix(cache): keep a header named __proto__ out of the prototype chain - #5667

Open
luantaraschi wants to merge 1 commit into
nodejs:mainfrom
luantaraschi:fix/cache-headers-proto-key
Open

fix(cache): keep a header named __proto__ out of the prototype chain#5667
luantaraschi wants to merge 1 commit into
nodejs:mainfrom
luantaraschi:fix/cache-headers-proto-key

Conversation

@luantaraschi

Copy link
Copy Markdown

This relates to...

No open issue. Extends the collision class that #5012 covered for delimiters.

Rationale

appendHeader in lib/util/cache.js accumulates onto a plain object and probes it by index:

const current = headers[headerName]

if (current === undefined) {
  headers[headerName] = ...
} else if (Array.isArray(current)) {
  current.push(...values)
} else {
  headers[headerName] = [current, ...values]
}

For __proto__ that probe returns Object.prototype, not undefined, so the header takes the last branch and the accumulator is assigned an array. Unlike a string, an array is accepted by the Object.prototype setter, so the assignment reprototypes the accumulator instead of storing the header.

An own __proto__ key is not exotic. JSON.parse produces one, which is how headers read from a config file or a request body arrive:

normalizeHeaders({ headers: JSON.parse('{"__proto__":"a","x-real":"same"}') })

Before:

Object.keys(headers)          -> [ 'x-real' ]
Object.getPrototypeOf(headers) -> [ [Object: null prototype] {}, 'a' ]   // an Array
headers.length                -> 2

Two problems from that one assignment. The header is gone, and the result now inherits from an array, so a lookup for a header named length answers 2 and at, map, keys and friends all resolve to functions. appendHeader itself branches on headers[headerName] === undefined, so a later header with one of those names takes the wrong branch.

The lost header then reaches makeDeduplicationKey, which rebuilds a plain object the same way, so two different requests produce one key:

makeDeduplicationKey({ ..., headers: JSON.parse('{"__proto__":"a","x-real":"same"}') })
makeDeduplicationKey({ ..., headers: { 'x-real': 'same' } })
// both: ["https://example.com","GET","/",{"x-real":"same"}]

The comment above that function says the key format was reworked because different header sets could produce identical keys (#5012). This is the same failure, reached through the header name instead of the value.

There is no Object.prototype pollution: the reprototyped object is the local accumulator, and the global prototype is untouched.

Changes

A setHeader helper writes the __proto__ key with Object.defineProperty, matching the guard parseHeaders already uses in lib/core/util.js. appendHeader probes with Object.hasOwn so an inherited name is not mistaken for an existing header, and makeDeduplicationKey writes through the same helper.

I kept the accumulator a plain object rather than switching it to a null prototype: it is handed to user-supplied cache stores as part of the cache key, and dropping Object.prototype from it would be a visible change for that code.

After:

Object.keys(headers)           -> [ '__proto__', 'x-real' ]
Object.getPrototypeOf(headers) -> Object.prototype
headers.length                 -> undefined
keys collide                   -> false

Two tests: one in test/cache-interceptor/cache-utils.js for the normalization, next to the existing prototype-pollution test there, and one in test/interceptors/deduplicate.js beside the #5012 collision test.

test/cache-interceptor 77 passing, test/interceptors 302 passing with 14 skipped as before, test/cache 4 passing.

Features

N/A

Bug Fixes

A header named __proto__ is no longer dropped from the normalized headers, no longer reprototypes the accumulator, and no longer makes two different requests share a deduplication key.

Breaking Changes and Deprecations

None.

Status

appendHeader probed the accumulator with headers[headerName], which returns
Object.prototype for __proto__ instead of undefined. The header then took the
append branch and assigned an array, and an array is accepted by the
Object.prototype setter, so the accumulator was reprototyped rather than
storing the header.

The result inherited from that array, so a header named length resolved to a
number, and the dropped header made two different requests produce the same
deduplication key, the same collision class as nodejs#5012.

Writes now go through a setHeader helper using Object.defineProperty, the
guard parseHeaders already uses, and the probe uses Object.hasOwn.
Copilot AI lite review requested due to automatic review settings August 7, 2026 18:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@luantaraschi

Copy link
Copy Markdown
Author

While checking where else this shape appears, two more spots drop the same header. Neither is in this PR and I would rather ask than send them unprompted.

buildRequestHeaders in lib/dispatcher/client-h2.js probes the same way, and for a string value the probe returns Object.prototype, which is truthy:

const current = headers[key]
headers[key] = current ? `${current}, ${val}` : val

So an h2 request carrying __proto__ would first be turned into "[object Object], a" and then dropped by the setter. Confirmed against that function in isolation: ['__proto__', 'a', 'x-real', 'same'] comes back as { 'x-real': 'same' }.

normalizeHeaders in lib/mock/snapshot-utils.js assigns straight into a plain object, so a recorded snapshot loses the header. Same input, same result.

The h2 one needs a real h2 round trip to test properly, and the mock one is a pure function so it is cheap. Happy to send either or both separately, in whatever order suits you, or to leave them if you would rather keep the surface small.

@luantaraschi

Copy link
Copy Markdown
Author

Both are up now, since #5663 was approved with the same guard: #5668 for the snapshot mock and #5669 for h2. Each stands on its own, so merge or drop them independently of this one.

The snapshot one turned out to have a second consequence I had not seen when I wrote the comment above: the dropped header makes two different requests share a snapshot key, so record mode collapses them and playback serves the wrong response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants