Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
"javascript.validate.enable": false,
"typescript.tsdk": "node_modules/typescript/lib",
"jest.enableInlineErrorMessages": true,
"cSpell.enabled": true
"cSpell.enabled": true,
"chat.tools.terminal.autoApprove": {
"yarn vitest": true
}
}
92 changes: 92 additions & 0 deletions __tests__/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -2953,6 +2953,98 @@ function runBaseTest(
expect(result.objConstructed).toEqual(new Object().constructor(1))
})

it("does not allow prototype pollution via reserved constructor access", () => {
const pollutedKey = "__immer_test_polluted__"
const original = Object.prototype[pollutedKey]

delete Object.prototype[pollutedKey]

try {
// The attack should either throw an error or silently fail
// but NOT pollute Object.prototype
let hadError = false
try {
produce({}, draft => {
draft.constructor.prototype[pollutedKey] = true
draft["__proto__"][pollutedKey] = true
})
} catch (e) {
// Expected: error when trying to set on undefined/frozen objects
hadError = true
}

// The critical check: Object.prototype must not be polluted
// either because we threw an error OR because the assignment was silently blocked
expect(Object.prototype[pollutedKey]).toBeUndefined()
} finally {
if (original === undefined) {
delete Object.prototype[pollutedKey]
} else {
Object.prototype[pollutedKey] = original
}
}
})

it("blocks prototype pollution via stored constructor reference (CVE bypass)", () => {
const pollutedKey = "__immer_test_ref__"
const original = Object.prototype[pollutedKey]

delete Object.prototype[pollutedKey]

try {
// Attack 3 from CVE: store constructor reference and mutate
let hadError = false
try {
produce({data: {}}, draft => {
const ctor = draft.data.constructor
ctor.prototype[pollutedKey] = true
})
} catch (e) {
hadError = true
}

expect(Object.prototype[pollutedKey]).toBeUndefined()
} finally {
if (original === undefined) {
delete Object.prototype[pollutedKey]
} else {
Object.prototype[pollutedKey] = original
}
}
})

it("blocks prototype pollution via Object.assign with malicious payload", () => {
const pollutedKey = "__immer_test_assign__"
const original = Object.prototype[pollutedKey]

delete Object.prototype[pollutedKey]

try {
// Simulates the real-world attack scenario where user input is Object.assign'd to draft
const userInput = {
constructor: {prototype: {[pollutedKey]: true}}
}

let hadError = false
try {
produce({}, draft => {
Object.assign(draft, userInput)
})
} catch (e) {
hadError = true
}

// Must NOT pollute via Object.assign path
expect(Object.prototype[pollutedKey]).toBeUndefined()
} finally {
if (original === undefined) {
delete Object.prototype[pollutedKey]
} else {
Object.prototype[pollutedKey] = original
}
}
})

it("should handle equality correctly - 1", () => {
const baseState = {
y: 3 / 0,
Expand Down
44 changes: 44 additions & 0 deletions src/core/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ export const objectTraps: ProxyHandler<ProxyState> = {
get(state, prop) {
if (prop === DRAFT_STATE) return state

// Guard against prototype pollution via constructor and __proto__
// We allow access but wrap in a proxy that blocks prototype chain traversal
if (prop === "constructor" || prop === "__proto__") {
const source = latest(state)
const value = source[prop]
// Return a proxy that allows calling the constructor but blocks access to prototype
return new Proxy(value || {}, {
get: (target, key) => {
// Block __proto__ and prototype access chains
if (key === "__proto__" || key === "prototype") {
return Object.freeze(Object.create(null))
}
// Allow normal property access for legitimate use
return Reflect.get(target, key)
},
set: () => {
// Silently ignore writes to prevent pollution
return true
},
apply: (target, thisArg, args) => {
// Allow constructor to be called as a function (e.g., draft.arr.constructor(1))
return Reflect.apply(target as Function, thisArg, args)
}
})
}

let arrayPlugin = state.scope_.arrayMethodsPlugin_
const isArrayWithStringProp =
state.type_ === ArchType.Array && typeof prop === "string"
Expand Down Expand Up @@ -157,6 +183,14 @@ export const objectTraps: ProxyHandler<ProxyState> = {
return value
},
has(state, prop) {
// Block reserved properties from being detected
if (
prop === "constructor" ||
prop === "__proto__" ||
prop === "prototype"
) {
return false
}
return prop in latest(state)
},
ownKeys(state) {
Expand All @@ -167,6 +201,16 @@ export const objectTraps: ProxyHandler<ProxyState> = {
prop: string /* strictly not, but helps TS */,
value
) {
// Guard against prototype pollution - prevent assignment to reserved properties
// that could lead to Object.prototype pollution
if (
prop === "constructor" ||
prop === "__proto__" ||
prop === "prototype"
) {
return true
}

const desc = getDescriptorFromProto(latest(state), prop)
if (desc?.set) {
// special case: if this write is captured by a setter, we have
Expand Down
Loading