diff --git a/__tests__/manual.js b/__tests__/manual.js index 265f44c0..6211fc54 100644 --- a/__tests__/manual.js +++ b/__tests__/manual.js @@ -23,7 +23,7 @@ function runTests(name, useProxies) { it("should check arguments", () => { expect(() => createDraft(3)).toThrowErrorMatchingSnapshot() - const buf = new Buffer([]) + const buf = Buffer.from([]) expect(() => createDraft(buf)).toThrowErrorMatchingSnapshot() expect(() => finishDraft({})).toThrowErrorMatchingSnapshot() }) diff --git a/__tests__/patch.js b/__tests__/patch.js index 7baba6e5..f9ca9f35 100644 --- a/__tests__/patch.js +++ b/__tests__/patch.js @@ -12,6 +12,8 @@ enableAllPlugins() jest.setTimeout(1000) +const isProd = process.env.NODE_ENV === "production" + function runPatchTest(base, producer, patches, inversePathes) { let resultProxies, resultEs5 @@ -1131,7 +1133,7 @@ test("#676 patching Date objects", () => { } const [nextState, patches] = produceWithPatches({}, function(draft) { - draft.date = new Date(2020, 10, 10, 8, 8, 8, 3) + draft.date = new Date("2020-11-10T08:08:08.003Z") draft.test = new Test() }) @@ -1145,5 +1147,112 @@ test("#676 patching Date objects", () => { expect(rebuilt.date.toJSON()).toMatchInlineSnapshot( `"2020-11-10T08:08:08.003Z"` ) - expect(rebuilt.date).toEqual(new Date(2020, 10, 10, 8, 8, 8, 3)) + expect(rebuilt.date).toEqual(new Date("2020-11-10T08:08:08.003Z")) +}) + +test("do not allow __proto__ polution - 738", () => { + const obj = {} + + // @ts-ignore + expect(obj.polluted).toBe(undefined) + expect(() => { + applyPatches({}, [ + {op: "add", path: ["__proto__", "polluted"], value: "yes"} + ]) + }).toThrow( + isProd + ? "24" + : "Patching reserved attributes like __proto__, prototype and constructor is not allowed" + ) + // @ts-ignore + expect(obj.polluted).toBe(undefined) +}) + +test("do not allow __proto__ polution using arrays - 738", () => { + const obj = {} + const ar = [] + + // @ts-ignore + expect(obj.polluted).toBe(undefined) + // @ts-ignore + expect(ar.polluted).toBe(undefined) + expect(() => { + applyPatches( + [], + [{op: "add", path: ["__proto__", "polluted"], value: "yes"}] + ) + }).toThrow( + isProd + ? "24" + : "Patching reserved attributes like __proto__, prototype and constructor is not allowed" + ) + // @ts-ignore + expect(obj.polluted).toBe(undefined) + // @ts-ignore + expect(ar.polluted).toBe(undefined) +}) + +test("do not allow prototype polution - 738", () => { + const obj = {} + + // @ts-ignore + expect(obj.polluted).toBe(undefined) + expect(() => { + applyPatches(Object, [ + {op: "add", path: ["prototype", "polluted"], value: "yes"} + ]) + }).toThrow( + isProd + ? "24" + : "Patching reserved attributes like __proto__, prototype and constructor is not allowed" + ) + // @ts-ignore + expect(obj.polluted).toBe(undefined) +}) + +test("do not allow constructor polution - 738", () => { + const obj = {} + + // @ts-ignore + expect(obj.polluted).toBe(undefined) + const t = {} + applyPatches(t, [{op: "replace", path: ["constructor"], value: "yes"}]) + expect(typeof t.constructor).toBe("function") + // @ts-ignore + expect(Object.polluted).toBe(undefined) +}) + +test("do not allow constructor.prototype polution - 738", () => { + const obj = {} + + // @ts-ignore + expect(obj.polluted).toBe(undefined) + expect(() => { + applyPatches({}, [ + {op: "add", path: ["constructor", "prototype", "polluted"], value: "yes"} + ]) + }).toThrow( + isProd + ? "24" + : "Patching reserved attributes like __proto__, prototype and constructor is not allowed" + ) + // @ts-ignore + expect(Object.polluted).toBe(undefined) +}) + +test("maps can store __proto__, prototype and constructor props", () => { + const obj = {} + const map = new Map() + map.set("__proto__", {}) + map.set("constructor", {}) + map.set("prototype", {}) + const newMap = applyPatches(map, [ + {op: "add", path: ["__proto__", "polluted"], value: "yes"}, + {op: "add", path: ["constructor", "polluted"], value: "yes"}, + {op: "add", path: ["prototype", "polluted"], value: "yes"} + ]) + expect(newMap.get("__proto__").polluted).toBe("yes") + expect(newMap.get("constructor").polluted).toBe("yes") + expect(newMap.get("prototype").polluted).toBe("yes") + expect(obj.polluted).toBe(undefined) }) diff --git a/docs/built-with.md b/docs/built-with.md index 3a786898..7c1dc566 100644 --- a/docs/built-with.md +++ b/docs/built-with.md @@ -13,6 +13,7 @@ title: Built with Immer - [redux-box](https://github.com/anish000kumar/redux-box) _Modular and easy-to-grasp redux based state management, with least boilerplate_ - [quick-redux](https://github.com/jeffreyyoung/quick-redux) _tools to make redux development quicker and easier_ - [bey](https://github.com/jamiebuilds/bey) _Simple immutable state for React using Immer_ +- [cool-store](https://github.com/Maxvien/cool-store) _CoolStore is an immutable state store built on top of ImmerJS and RxJS_ - [immer-wieder](https://github.com/drcmda/immer-wieder#readme) _State management lib that combines React 16 Context and immer for Redux semantics_ - [robodux](https://github.com/neurosnap/robodux) _flexible way to reduce redux boilerplate_ - [immer-reducer](https://github.com/epeli/immer-reducer) _Type-safe and terse React (useReducer()) and Redux reducers with Typescript_ diff --git a/docs/current.md b/docs/current.md index fdc2878a..caa8e7af 100644 --- a/docs/current.md +++ b/docs/current.md @@ -8,7 +8,7 @@ sidebar_label: Current
-Immer exposes a named export `current` that create a copy of the current state of the draft. This can be very useful for debugging purposes (as those objects won't be Proxy objects and not be logged as such). Also, references to `current` can be safely leaked from a produce function. Put differently, `current` provides a snapshot of the current state of a draft. +Immer exposes a named export `current` that creates a copy of the current state of the draft. This can be very useful for debugging purposes (as those objects won't be Proxy objects and not be logged as such). Also, references to `current` can be safely leaked from a produce function. Put differently, `current` provides a snapshot of the current state of a draft. Objects generated by `current` work similar to the objects created by produce itself. diff --git a/docs/example-reducer.md b/docs/example-reducer.md index e75c519a..701d3804 100644 --- a/docs/example-reducer.md +++ b/docs/example-reducer.md @@ -19,9 +19,10 @@ title: Example Reducer Here is a simple example of the difference that Immer could make in practice. ```javascript -// Redux reducer +// Reducer with inital state +const INITAL_STATE = {}; // Shortened, based on: https://github.com/reactjs/redux/blob/master/examples/shopping-cart/src/reducers/products.js -const byId = (state = {}, action) => { +const byId = (state = INITAL_STATE, action) => { switch (action.type) { case RECEIVE_PRODUCTS: return { @@ -42,6 +43,9 @@ After using Immer, our reducer can be expressed as: ```javascript import produce from "immer" +// Reducer with inital state +const INITAL_STATE = {}; + const byId = produce((draft, action) => { switch (action.type) { case RECEIVE_PRODUCTS: @@ -49,7 +53,7 @@ const byId = produce((draft, action) => { draft[product.id] = product }) } -}, {}) +}, INITAL_STATE) ``` Notice that it is not necessary to handle the default case, a producer that doesn't do anything will return the original state. diff --git a/docs/performance.md b/docs/performance.md index 7c679d9f..7947d7b6 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -44,7 +44,7 @@ Most important observation: ### Pre-freeze data -When adding a large data set to the state tree in an Immer producer (for example data received from a JSON endpoint), it is worth to call `freeze(json)` on the root of the data to be added first. This will allow Immer to add the new data to the tree faster, as it will skip _recursively_ freezing it, or searching the new data for any changes (drafts) that might be made. +When adding a large data set to the state tree in an Immer producer (for example data received from a JSON endpoint), it is worth to call `freeze(json)` on the root of the data that is being added first. To _shallowly_ freeze it. This will allow Immer to add the new data to the tree faster, as it will avoid the need to _recursively_ scan and freeze the new data. ### You can always opt-out diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 22949c8f..ab308074 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -61,7 +61,7 @@ The set of patches generated by Immer should be correct, that is, applying them ### Always use the result of nested producers -Nested `produce` calls are supported, but note that `produce` will _always_ produce a new state, so even when passing a draft to a nested produce, the changes made by the inner produce won't be visibile in the draft that was passed it, but only in the output that is produced. In other words, when using nested produce, you get a draft of a draft and the result of the inner produce should be merged back into the original draft (or returned). For example `produce(state, draft => { produce(draft.user, userDraft => { userDraft.name += "!" })})` won't work as the output if the inner produce isn't used. The correct way to use nested producers is: +Nested `produce` calls are supported, but note that `produce` will _always_ produce a new state, so even when passing a draft to a nested produce, the changes made by the inner produce won't be visible in the draft that was passed it, but only in the output that is produced. In other words, when using nested produce, you get a draft of a draft and the result of the inner produce should be merged back into the original draft (or returned). For example `produce(state, draft => { produce(draft.user, userDraft => { userDraft.name += "!" })})` won't work as the output if the inner produce isn't used. The correct way to use nested producers is: ```javascript produce(state, draft => { @@ -70,3 +70,22 @@ produce(state, draft => { }) }) ``` + +### Drafts aren't referentially equal + +Draft objects in Immer are wrapped in `Proxy`, so you cannot use `==` or `===` to test equality between an original object and its equivalent draft (eg. when matching a specific element in an array). Instead, you can use the `original` helper: + +```javascript +const remove = produce((list, element) => { + const index = list.indexOf(element) // this won't work! + const index = original(list).indexOf(element) // do this instead + if (index > -1) list.splice(index, 1) +}) + +const values = [a, b, c] +remove(values, a) +``` + +If possible, it's recommended to perform the comparison outside the `produce` function, or to use a unique identifier property like `.id` instead, to avoid needing to use `original`. + + diff --git a/src/core/immerClass.ts b/src/core/immerClass.ts index 3e1c53df..f98d3756 100644 --- a/src/core/immerClass.ts +++ b/src/core/immerClass.ts @@ -164,7 +164,7 @@ export class Immer implements ProducersFns { /** * Pass true to automatically freeze all copies created by Immer. * - * By default, auto-freezing is disabled in production. + * By default, auto-freezing is enabled. */ setAutoFreeze(value: boolean) { this.autoFreeze_ = value diff --git a/src/immer.ts b/src/immer.ts index 53455494..0eacffe6 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -55,7 +55,7 @@ export const produceWithPatches: IProduceWithPatches = immer.produceWithPatches. /** * Pass true to automatically freeze all copies created by Immer. * - * By default, auto-freezing is disabled in production. + * Always freeze by default, even in production mode */ export const setAutoFreeze = immer.setAutoFreeze.bind(immer) diff --git a/src/plugins/patches.ts b/src/plugins/patches.ts index 998627f2..a06a526a 100644 --- a/src/plugins/patches.ts +++ b/src/plugins/patches.ts @@ -26,7 +26,8 @@ import { ArchtypeArray, die, isDraft, - isDraftable + isDraftable, + ArchtypeObject } from "../internal" export function enablePatches() { @@ -211,7 +212,16 @@ export function enablePatches() { let base: any = draft for (let i = 0; i < path.length - 1; i++) { - base = get(base, path[i]) + const parentType = getArchtype(base) + const p = path[i] + // See #738, avoid prototype pollution + if ( + (parentType === ArchtypeObject || parentType === ArchtypeArray) && + (p === "__proto__" || p === "constructor") + ) + die(24) + if (typeof base === "function" && p === "prototype") die(24) + base = get(base, p) if (typeof base !== "object") die(15, path.join("/")) } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index b73e2aac..8cab0f9a 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -38,7 +38,8 @@ const errors = { }, 23(thing: string) { return `'original' expects a draft, got: ${thing}` - } + }, + 24: "Patching reserved attributes like __proto__, prototype and constructor is not allowed" } as const export function die(error: keyof typeof errors, ...args: any[]): never { diff --git a/website/yarn.lock b/website/yarn.lock index b0dd3ab9..1e600684 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2187,9 +2187,9 @@ domutils@^1.5.1, domutils@^1.7.0: domelementtype "1" dot-prop@^4.1.1: - version "4.2.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" - integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== + version "4.2.1" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4" + integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ== dependencies: is-obj "^1.0.0" @@ -3155,9 +3155,9 @@ hex-color-regex@^1.1.0: integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== highlight.js@^9.15.8: - version "9.15.10" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" - integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw== + version "9.18.5" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.5.tgz#d18a359867f378c138d6819edfc2a8acd5f29825" + integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== hosted-git-info@^2.1.4: version "2.8.4" @@ -3352,9 +3352,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== inquirer@6.5.0: version "6.5.0" diff --git a/yarn.lock b/yarn.lock index 30bb9966..5c91e700 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5790,9 +5790,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== init-package-json@^1.10.3: version "1.10.3" @@ -7866,10 +7866,10 @@ marked-terminal@^4.0.0: node-emoji "^1.10.0" supports-hyperlinks "^2.0.0" -marked@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.0.tgz#ec5c0c9b93878dc52dd54be8d0e524097bd81a99" - integrity sha512-MyUe+T/Pw4TZufHkzAfDj6HarCBWia2y27/bhuYkTaiUnfDYFnCP3KUN+9oM7Wi6JA2rymtVYbQu3spE0GCmxQ== +marked@^1.0.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.4.tgz#94e99230b03496c9383b1322ac51bc17dd388a1d" + integrity sha512-6x5TFGCTKSQBLTZtOburGxCxFEBJEGYVLwCMTBCxzvyuisGcC20UNzDSJhCr/cJ/Kmh6ulfJm10g6WWEAJ3kvg== math-expression-evaluator@^1.2.14: version "1.2.22" @@ -10527,9 +10527,9 @@ seamless-immutable@^7.1.3: integrity sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A== semantic-release@^17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.0.2.tgz#0b6ca76b17092ba0697a9bd3d210178b57a232d7" - integrity sha512-f2466mNS/TpY32Jvoqgu3ricIDX/TRZXuthcyJo3ZIfdI14uMfiOu5R2dFKnPwgJh4wa9/2ckL44AFmIXAhiyg== + version "17.2.3" + resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.2.3.tgz#11f10b851d4e75b1015b17515c433049b3df994c" + integrity sha512-MY1MlowGQrkOR7+leOD8ICkVOC6i1szbwDODdbJ0UdshtMx8Ms0bhpRQmEEliqYKEb5PLv/dqs6zKKuHT7UxTg== dependencies: "@semantic-release/commit-analyzer" "^8.0.0" "@semantic-release/error" "^2.2.0" @@ -10548,14 +10548,14 @@ semantic-release@^17.0.2: hook-std "^2.0.0" hosted-git-info "^3.0.0" lodash "^4.17.15" - marked "^0.8.0" + marked "^1.0.0" marked-terminal "^4.0.0" micromatch "^4.0.2" p-each-series "^2.1.0" p-reduce "^2.0.0" read-pkg-up "^7.0.0" resolve-from "^5.0.0" - semver "^7.1.1" + semver "^7.3.2" semver-diff "^3.1.1" signale "^1.2.1" yargs "^15.0.1" @@ -10599,10 +10599,10 @@ semver@^6.0.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.1.1, semver@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.2.tgz#847bae5bce68c5d08889824f02667199b70e3d87" - integrity sha512-BJs9T/H8sEVHbeigqzIEo57Iu/3DG6c4QoqTfbQB3BPA4zgzAomh/Fk9E7QtjWQ8mx2dgA9YCfSF4y9k9bHNpQ== +semver@^7.1.1, semver@^7.1.2, semver@^7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" + integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== serialize-javascript@^2.1.2: version "2.1.2"