🐛 Bug Report
Since 11.1.9 (still present in 11.1.11), reading constructor on a draft returns a wrapper Proxy whose prototype read returns a fabricated value. Because a function's prototype is a non-configurable, non-writable data property, returning anything else violates a Proxy invariant and throws:
TypeError: 'get' on proxy: property 'prototype' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected '#<Object>' but got '[object Object]')
Any ecosystem code that reads value.constructor.prototype on a draft now crashes. The most common trigger is lodash.isEmpty() on a draft inside a Redux Toolkit reducer (lodash reads value.constructor.prototype); it also hits lodash.isPlainObject, constructor === Object brand checks, and drafts. carrying a data key named constructor.
Introduced by 48fc378 ("prevent prototype pollution via constructor.prototype access", #1259).
Link to repro
Minimal repository: https://github.com/jonatankruszewski/immer-lodash-isempty-repro (npm install && npm run repro).
You can clone and follow the README.md or do a small reproduction locally as follows:
To Reproduce
Steps to reproduce the behavior:
npm install immer@11.1.11 @reduxjs/toolkit@2.12.0 lodash@4.18.1
- Run:
const {configureStore, createSlice} = require("@reduxjs/toolkit")
const _ = require("lodash")
const slice = createSlice({
name: "demo",
initialState: {settings: {options: {}}},
reducers: {
setOptions(state, action) {
_.isEmpty(state.settings.options) // throws on immer >= 11.1.9
state.settings.options = action.payload
}
}
})
const store = configureStore({reducer: {demo: slice.reducer}})
store.dispatch(slice.actions.setOptions({items: [{id: 1}]})) // TypeError
Dependency-free equivalent:
const {produce} = require("immer")
produce({}, d => {
d.constructor.prototype
}) // TypeError (Proxy invariant)
Observed behavior
lodash.isEmpty(draft) (and any read of draft.constructor.prototype) throws the TypeError above instead of returning a value. 11.1.8 works; 11.1.9+ throws.
This is faulty because the constructor guard added in 11.1.9 cannot actually protect anything — it only crashes. On a draft, draft.constructor is the real Object and draft.constructor.prototype is the real Object.prototype, so draft.constructor.prototype.x = 1 is literally Object.prototype.x = 1, plain JavaScript reachable in any reducer with or without a draft. Immer is not the vector, and it cannot intercept this without
returning a fake constructor (which would break constructor === Object).
Measured across every pollution vector (via immer vs the identical operation in plain JS with no immer — npm run probe in the repo):
| Vector |
11.1.8 |
11.1.11 (guard) |
plain JS |
draft.constructor.prototype.x = 1 |
pollutes |
throws |
pollutes |
draft.__proto__.x = 1 |
pollutes |
clean |
pollutes |
draft.__proto__ = {…} |
throws¹ |
clean |
pollutes |
applyPatches(__proto__ path) |
throws |
throws |
n/a |
¹ pre-existing setPrototypeOf trap.
The guard's only effect on the constructor.prototype vector is the crash. It also causes collateral regressions vs 11.1.8 (npm run probe in the repo shows this dependency-free):
| Behavior |
11.1.8 |
11.1.9+ |
lodash.isEmpty(draft) |
works |
throws TypeError |
draft.constructor.prototype (read) |
works |
throws TypeError |
produce({constructor: 'x'}, d => d.constructor) |
works |
throws TypeError |
draft.constructor === Object |
true |
false |
'constructor' in draft |
true |
false |
draft.prototype = 42 (own data key) |
persisted |
silently dropped |
Expected behavior
Reading constructor / constructor.prototype on a draft should return the real values and not throw, so ecosystem inspection (lodash.isEmpty, constructor === Object) keeps working, while the genuine prototype-pollution protections are preserved.
I have a working fix (all 3733 tests pass): keep the __proto__ sanitizing wrapper (which cleanly blocks draft.__proto__.x = 1 and is invariant-safe because __proto__ resolves to a plain object), and drop only the constructor wrapper and the has/set reserved-key blocks. setPrototypeOf and patch-layer
guards are untouched, so the result is strictly safer than 11.1.8. It adds tests that distinguish a clean block from a crash, including unit tests for the __proto__ guard wrapper.
Environment
🐛 Bug Report
Since 11.1.9 (still present in 11.1.11), reading
constructoron a draft returns a wrapperProxywhoseprototyperead returns a fabricated value. Because a function'sprototypeis a non-configurable, non-writable data property, returning anything else violates a Proxy invariant and throws:Any ecosystem code that reads
value.constructor.prototypeon a draft now crashes. The most common trigger islodash.isEmpty()on a draft inside a Redux Toolkit reducer (lodash readsvalue.constructor.prototype); it also hitslodash.isPlainObject,constructor === Objectbrand checks, and drafts. carrying a data key namedconstructor.Introduced by
48fc378("prevent prototype pollution via constructor.prototype access", #1259).Link to repro
Minimal repository: https://github.com/jonatankruszewski/immer-lodash-isempty-repro (
npm install && npm run repro).You can clone and follow the README.md or do a small reproduction locally as follows:
To Reproduce
Steps to reproduce the behavior:
npm install immer@11.1.11 @reduxjs/toolkit@2.12.0 lodash@4.18.1Dependency-free equivalent:
Observed behavior
lodash.isEmpty(draft)(and any read ofdraft.constructor.prototype) throws theTypeErrorabove instead of returning a value.11.1.8works;11.1.9+ throws.This is faulty because the
constructorguard added in 11.1.9 cannot actually protect anything — it only crashes. On a draft,draft.constructoris the realObjectanddraft.constructor.prototypeis the realObject.prototype, sodraft.constructor.prototype.x = 1is literallyObject.prototype.x = 1, plain JavaScript reachable in any reducer with or without a draft. Immer is not the vector, and it cannot intercept this withoutreturning a fake
constructor(which would breakconstructor === Object).Measured across every pollution vector (
via immervs the identical operation in plain JS with no immer —npm run probein the repo):draft.constructor.prototype.x = 1draft.__proto__.x = 1draft.__proto__ = {…}applyPatches(__proto__ path)¹ pre-existing
setPrototypeOftrap.The guard's only effect on the
constructor.prototypevector is the crash. It also causes collateral regressions vs 11.1.8 (npm run probein the repo shows this dependency-free):lodash.isEmpty(draft)TypeErrordraft.constructor.prototype(read)TypeErrorproduce({constructor: 'x'}, d => d.constructor)TypeErrordraft.constructor === Objecttruefalse'constructor' in drafttruefalsedraft.prototype = 42(own data key)Expected behavior
Reading
constructor/constructor.prototypeon a draft should return the real values and not throw, so ecosystem inspection (lodash.isEmpty,constructor === Object) keeps working, while the genuine prototype-pollution protections are preserved.I have a working fix (all 3733 tests pass): keep the
__proto__sanitizing wrapper (which cleanly blocksdraft.__proto__.x = 1and is invariant-safe because__proto__resolves to a plain object), and drop only theconstructorwrapper and thehas/setreserved-key blocks.setPrototypeOfand patch-layerguards are untouched, so the result is strictly safer than 11.1.8. It adds tests that distinguish a clean block from a crash, including unit tests for the
__proto__guard wrapper.Environment
11.1.11(first bad:11.1.9;11.1.8works)setUseProxies(true)(proxies are the only mode in v11)setUseProxies(false)(ES5 only) — N/A, ES5 mode was removed in immer v10