Skip to content

lodash.isEmpty() on a draft throws "TypeError: 'get' on proxy: property 'prototype'..." since 11.1.9 #1268

Description

@jonatankruszewski

🐛 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:

  1. npm install immer@11.1.11 @reduxjs/toolkit@2.12.0 lodash@4.18.1
  2. 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

  • Immer version: 11.1.11 (first bad: 11.1.9; 11.1.8 works)
  • I filed this report against the latest version of Immer
  • Occurs with setUseProxies(true) (proxies are the only mode in v11)
  • Occurs with setUseProxies(false) (ES5 only) — N/A, ES5 mode was removed in immer v10

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions