Skip to content

Commit 3aa6c97

Browse files
lodyai[bot]lody-ai
andauthored
fix(mirror): treat undefined values in Map as non-existent fields (#66)
* fix(mirror): treat undefined values in Map as non-existent fields When setting state with objects containing undefined values like { name: 'test', age: undefined }, the undefined fields should be treated as if they don't exist, rather than being converted to null. Changes: - diffMap: Skip undefined values when iterating newStateObj, and generate delete changes if the old value existed - initializeContainer: Skip undefined values when initializing Map - stripUndefined: New utility function to recursively remove undefined values from objects while preserving non-enumerable properties () - setState: Apply stripUndefined to newState before storing This prevents state divergence errors when users pass objects with undefined values to setState. Co-authored-by: lody <agent@lody.ai> * fix(utils): prevent prototype pollution in stripUndefined - Use Object.create(null) instead of {} to create result objects - Skip unsafe keys (__proto__, constructor, prototype) - Use Object.defineProperty instead of direct assignment for copying values - Use Map instead of plain object for strippedValues to avoid pollution This prevents attackers from polluting Object.prototype via JSON input containing __proto__ or similar keys. Co-authored-by: lody <agent@lody.ai> * chore: remove package-lock.json files (use pnpm) Co-authored-by: lody <agent@lody.ai> * test: add assertions for undefined field non-existence - Assert that undefined fields do not exist in state using 'key in obj' - Assert that accessing undefined fields returns undefined - Add test case for deleting existing field by setting to undefined Co-authored-by: lody <agent@lody.ai> --------- Co-authored-by: Lody Agent <agent@lody.ai>
1 parent e222c72 commit 3aa6c97

4 files changed

Lines changed: 262 additions & 1 deletion

File tree

packages/core/src/core/diff.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,34 @@ export function diffMap<S extends ObjectLike>(
12821282
const oldItem = oldStateObj[key];
12831283
const newItem = newStateObj[key];
12841284

1285+
// Treat undefined values as non-existent fields.
1286+
// This allows users to pass objects with undefined values without causing errors.
1287+
if (newItem === undefined) {
1288+
// If old item exists, we need to delete it
1289+
if (key in oldStateObj && oldItem !== undefined) {
1290+
const childSchemaForDelete = getMapChildSchema(
1291+
schema as
1292+
| LoroMapSchema<Record<string, SchemaType>>
1293+
| LoroMapSchemaWithCatchall<
1294+
Record<string, SchemaType>,
1295+
SchemaType
1296+
>
1297+
| RootSchemaType<Record<string, ContainerSchemaType>>
1298+
| undefined,
1299+
key,
1300+
);
1301+
if (!(childSchemaForDelete && childSchemaForDelete.type === "ignore")) {
1302+
changes.push({
1303+
container: containerId,
1304+
key,
1305+
value: undefined,
1306+
kind: "delete",
1307+
});
1308+
}
1309+
}
1310+
continue;
1311+
}
1312+
12851313
// Figure out if the modified new value is a container
12861314
const childSchema = getMapChildSchema(
12871315
schema as

packages/core/src/core/mirror.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
tryInferContainerType,
5050
getRootContainerByType,
5151
defineCidProperty,
52+
stripUndefined,
5253
} from "./utils.js";
5354
import { diffContainer, diffTree } from "./diff.js";
5455
import { CID_KEY } from "../constants.js";
@@ -1644,6 +1645,8 @@ export class Mirror<S extends SchemaType> {
16441645
for (const [key, val] of Object.entries(value)) {
16451646
// Skip injected CID field
16461647
if (key === CID_KEY) continue;
1648+
// Skip undefined values - treat them as non-existent fields
1649+
if (val === undefined) continue;
16471650
if (mapSchema) {
16481651
const fieldSchema = this.getSchemaForMapKey(mapSchema, key);
16491652

@@ -2088,7 +2091,9 @@ export class Mirror<S extends SchemaType> {
20882091
// Refresh in-memory state from Doc to capture assigned IDs (e.g., TreeIDs)
20892092
// and any canonical normalization (like Tree meta->data mapping).
20902093
this.updateLoro(newState, options);
2091-
this.state = newState;
2094+
// Strip undefined values from the state to match LoroDoc behavior
2095+
// (undefined values are treated as non-existent fields)
2096+
this.state = stripUndefined(newState);
20922097
const shouldCheck = this.options.checkStateConsistency;
20932098
if (shouldCheck) {
20942099
this.checkStateConsistency();

packages/core/src/core/utils.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,85 @@ export function isObject(value: unknown): value is Record<string, unknown> {
3030
);
3131
}
3232

33+
// Keys that could cause prototype pollution if assigned directly
34+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
35+
36+
/**
37+
* Recursively removes undefined values from an object.
38+
* This treats undefined values as non-existent fields.
39+
* Preserves non-enumerable properties like $cid.
40+
* Returns the original object if no undefined values are found.
41+
* Protects against prototype pollution by skipping unsafe keys.
42+
*/
43+
export function stripUndefined<T>(value: T): T {
44+
if (value === undefined) {
45+
return value;
46+
}
47+
if (Array.isArray(value)) {
48+
let hasChanges = false;
49+
const result = value.map((item) => {
50+
const stripped = stripUndefined(item);
51+
if (stripped !== item) hasChanges = true;
52+
return stripped;
53+
});
54+
return hasChanges ? (result as T) : value;
55+
}
56+
if (isObject(value)) {
57+
// Check if any enumerable property is undefined or needs stripping
58+
let hasUndefined = false;
59+
let hasNestedChanges = false;
60+
const strippedValues: Map<string, unknown> = new Map();
61+
62+
for (const key of Object.keys(value)) {
63+
// Skip unsafe keys to prevent prototype pollution
64+
if (UNSAFE_KEYS.has(key)) {
65+
continue;
66+
}
67+
const val = value[key];
68+
if (val === undefined) {
69+
hasUndefined = true;
70+
} else {
71+
const stripped = stripUndefined(val);
72+
strippedValues.set(key, stripped);
73+
if (stripped !== val) {
74+
hasNestedChanges = true;
75+
}
76+
}
77+
}
78+
79+
// If no changes needed, return original object
80+
if (!hasUndefined && !hasNestedChanges) {
81+
return value;
82+
}
83+
84+
// Use Object.create(null) to avoid prototype pollution
85+
const result = Object.create(null) as Record<string, unknown>;
86+
// Copy non-enumerable properties (like $cid) first
87+
const allProps = Object.getOwnPropertyNames(value);
88+
for (const key of allProps) {
89+
// Skip unsafe keys
90+
if (UNSAFE_KEYS.has(key)) {
91+
continue;
92+
}
93+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
94+
if (descriptor && !descriptor.enumerable) {
95+
Object.defineProperty(result, key, descriptor);
96+
}
97+
}
98+
// Copy the stripped values using Object.defineProperty to be safe
99+
for (const [key, val] of strippedValues) {
100+
Object.defineProperty(result, key, {
101+
value: val,
102+
writable: true,
103+
enumerable: true,
104+
configurable: true,
105+
});
106+
}
107+
return result as T;
108+
}
109+
return value;
110+
}
111+
33112
/**
34113
* Performs a deep equality check between two values
35114
*/
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, it, expect } from "vitest";
2+
import { LoroDoc } from "loro-crdt";
3+
import { Mirror, schema } from "../src/index.js";
4+
5+
describe("undefined values in Map should be treated as non-existent fields", () => {
6+
it("should ignore undefined fields when setting state", async () => {
7+
const testSchema = schema({
8+
root: schema.LoroMap({
9+
name: schema.String(),
10+
age: schema.Number(),
11+
}),
12+
});
13+
14+
const doc = new LoroDoc();
15+
const mirror = new Mirror({
16+
doc,
17+
schema: testSchema,
18+
checkStateConsistency: true,
19+
});
20+
21+
// Try to set a field to undefined - this should be treated as if the field doesn't exist
22+
expect(() => {
23+
mirror.setState({
24+
root: {
25+
name: "test",
26+
age: undefined, // This undefined should be ignored
27+
},
28+
} as any);
29+
}).not.toThrow();
30+
31+
const state = mirror.getState() as any;
32+
33+
// The name field should be set
34+
expect(state.root.name).toBe("test");
35+
// The age field should not exist in the state (key should not be present)
36+
expect("age" in state.root).toBe(false);
37+
expect(state.root.age).toBeUndefined();
38+
});
39+
40+
it("should ignore undefined fields in nested maps", async () => {
41+
const testSchema = schema({
42+
root: schema.LoroMap({
43+
user: schema.LoroMap({
44+
name: schema.String(),
45+
email: schema.String(),
46+
}),
47+
}),
48+
});
49+
50+
const doc = new LoroDoc();
51+
const mirror = new Mirror({
52+
doc,
53+
schema: testSchema,
54+
checkStateConsistency: true,
55+
});
56+
57+
expect(() => {
58+
mirror.setState({
59+
root: {
60+
user: {
61+
name: "John",
62+
email: undefined, // Should be ignored
63+
},
64+
},
65+
} as any);
66+
}).not.toThrow();
67+
68+
const state = mirror.getState() as any;
69+
expect(state.root.user.name).toBe("John");
70+
// The email field should not exist in the state
71+
expect("email" in state.root.user).toBe(false);
72+
expect(state.root.user.email).toBeUndefined();
73+
});
74+
75+
it("should handle undefined in schema.Any fields", async () => {
76+
const testSchema = schema({
77+
root: schema.LoroMap({
78+
data: schema.Any(),
79+
}),
80+
});
81+
82+
const doc = new LoroDoc();
83+
const mirror = new Mirror({
84+
doc,
85+
schema: testSchema,
86+
checkStateConsistency: true,
87+
});
88+
89+
expect(() => {
90+
mirror.setState({
91+
root: {
92+
data: {
93+
field1: "value",
94+
field2: undefined, // Should be ignored
95+
},
96+
},
97+
} as any);
98+
}).not.toThrow();
99+
100+
const state = mirror.getState() as any;
101+
expect(state.root.data.field1).toBe("value");
102+
// The field2 should not exist in the state
103+
expect("field2" in state.root.data).toBe(false);
104+
expect(state.root.data.field2).toBeUndefined();
105+
});
106+
107+
it("should delete existing field when set to undefined", async () => {
108+
const testSchema = schema({
109+
root: schema.LoroMap({
110+
name: schema.String(),
111+
age: schema.Number(),
112+
}),
113+
});
114+
115+
const doc = new LoroDoc();
116+
const mirror = new Mirror({
117+
doc,
118+
schema: testSchema,
119+
checkStateConsistency: true,
120+
});
121+
122+
// First set both fields
123+
mirror.setState({
124+
root: {
125+
name: "test",
126+
age: 25,
127+
},
128+
} as any);
129+
130+
let state = mirror.getState() as any;
131+
expect(state.root.name).toBe("test");
132+
expect(state.root.age).toBe(25);
133+
expect("age" in state.root).toBe(true);
134+
135+
// Now set age to undefined - it should be deleted
136+
mirror.setState({
137+
root: {
138+
name: "test",
139+
age: undefined,
140+
},
141+
} as any);
142+
143+
state = mirror.getState() as any;
144+
expect(state.root.name).toBe("test");
145+
// The age field should be deleted
146+
expect("age" in state.root).toBe(false);
147+
expect(state.root.age).toBeUndefined();
148+
});
149+
});

0 commit comments

Comments
 (0)