Skip to content

Commit da0f7d8

Browse files
committed
fix: serialize @persist state for RPC returns
Clones state before returning from RPC to avoid RpcStub serialization. Closes #99
1 parent 6bbf82b commit da0f7d8

3 files changed

Lines changed: 231 additions & 12 deletions

File tree

packages/core/src/index.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { env, DurableObject, WorkerEntrypoint } from "cloudflare:workers";
22
import { Storage } from "../../storage/src/index";
33
import { Alarms } from "../../alarms/src/index";
44
import { Sockets } from "../../sockets/src/index";
5-
import { Persist, PERSISTED_VALUES, initializePersistedProperties, persistProperty } from "./persist";
5+
import { Persist, PERSISTED_VALUES, initializePersistedProperties, persistProperty, unwrapProxy } from "./persist";
66

77
export { Persist };
88

@@ -183,8 +183,43 @@ export abstract class Actor<E> extends DurableObject<E> {
183183
if (!this.name) {
184184
this._name = DEFAULT_ACTOR_NAME;
185185
}
186+
187+
// Wrap RPC methods to unwrap proxy values for serialization
188+
this._wrapMethodsForRpc();
186189
}
187-
190+
191+
/**
192+
* Wraps all public methods to unwrap proxy return values for RPC serialization.
193+
* @private
194+
*/
195+
private _wrapMethodsForRpc(): void {
196+
const skipMethods = new Set([
197+
'constructor', '_wrapMethodsForRpc', '_initializePersistedProperties',
198+
'_waitForSetName', '_persistProperty', 'setName'
199+
]);
200+
201+
let proto = Object.getPrototypeOf(this);
202+
while (proto && proto !== Object.prototype) {
203+
for (const name of Object.getOwnPropertyNames(proto)) {
204+
if (skipMethods.has(name) || name.startsWith('_')) continue;
205+
206+
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
207+
if (!descriptor || typeof descriptor.value !== 'function') continue;
208+
209+
const original = descriptor.value;
210+
const self = this;
211+
(this as Record<string, unknown>)[name] = function(...args: unknown[]) {
212+
const result = original.apply(self, args);
213+
if (result instanceof Promise) {
214+
return result.then((v: unknown) => unwrapProxy(v));
215+
}
216+
return unwrapProxy(result);
217+
};
218+
}
219+
proto = Object.getPrototypeOf(proto);
220+
}
221+
}
222+
188223
/**
189224
* Initializes the persisted properties table and loads any stored values.
190225
* This is called during construction to ensure properties are loaded before any code uses them.

packages/core/src/persist.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from "vitest";
2+
import { unwrapProxy } from "./persist";
3+
4+
describe("unwrapProxy", () => {
5+
describe("prototype pollution prevention", () => {
6+
it("ignores __proto__ key in plain objects", () => {
7+
const malicious = JSON.parse('{"__proto__": {"polluted": true}, "safe": 1}');
8+
const result = unwrapProxy(malicious);
9+
10+
expect(result.safe).toBe(1);
11+
expect(result.__proto__).toBeUndefined();
12+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
13+
});
14+
15+
it("ignores constructor key", () => {
16+
const malicious = { constructor: { prototype: { polluted: true } }, safe: 1 };
17+
const result = unwrapProxy(malicious);
18+
19+
expect(result.safe).toBe(1);
20+
expect(result.constructor).toBeUndefined();
21+
});
22+
23+
it("ignores prototype key", () => {
24+
const malicious = { prototype: { polluted: true }, safe: 1 };
25+
const result = unwrapProxy(malicious);
26+
27+
expect(result.safe).toBe(1);
28+
expect(result.prototype).toBeUndefined();
29+
});
30+
31+
it("filters dangerous keys in nested objects", () => {
32+
const malicious = {
33+
nested: JSON.parse('{"__proto__": {"polluted": true}, "valid": 2}'),
34+
safe: 1,
35+
};
36+
const result = unwrapProxy(malicious);
37+
38+
expect(result.safe).toBe(1);
39+
expect(result.nested.valid).toBe(2);
40+
expect(result.nested.__proto__).toBeUndefined();
41+
});
42+
43+
it("result object has null prototype", () => {
44+
const input = { a: 1 };
45+
const result = unwrapProxy(input);
46+
47+
expect(Object.getPrototypeOf(result)).toBeNull();
48+
});
49+
});
50+
51+
describe("basic functionality", () => {
52+
it("handles primitives", () => {
53+
expect(unwrapProxy(null)).toBeNull();
54+
expect(unwrapProxy(undefined)).toBeUndefined();
55+
expect(unwrapProxy(42)).toBe(42);
56+
expect(unwrapProxy("str")).toBe("str");
57+
expect(unwrapProxy(true)).toBe(true);
58+
});
59+
60+
it("handles arrays", () => {
61+
const input = [1, { a: 2 }, [3]];
62+
const result = unwrapProxy(input);
63+
64+
expect(result).toEqual([1, { a: 2 }, [3]]);
65+
expect(Array.isArray(result)).toBe(true);
66+
});
67+
68+
it("handles nested objects", () => {
69+
const input = { a: { b: { c: 1 } } };
70+
const result = unwrapProxy(input);
71+
72+
expect(result.a.b.c).toBe(1);
73+
});
74+
75+
it("handles circular references", () => {
76+
const obj: Record<string, unknown> = { a: 1 };
77+
obj.self = obj;
78+
79+
const result = unwrapProxy(obj);
80+
81+
expect(result.a).toBe(1);
82+
expect(result.self).toBe(result);
83+
});
84+
85+
it("handles Map", () => {
86+
const input = new Map([["key", { value: 1 }]]);
87+
const result = unwrapProxy(input);
88+
89+
expect(result instanceof Map).toBe(true);
90+
expect(result.get("key")).toEqual({ value: 1 });
91+
});
92+
93+
it("handles Set", () => {
94+
const input = new Set([1, 2, { a: 3 }]);
95+
const result = unwrapProxy(input);
96+
97+
expect(result instanceof Set).toBe(true);
98+
expect(result.size).toBe(3);
99+
});
100+
101+
it("preserves Date instances", () => {
102+
const date = new Date("2024-01-01");
103+
const result = unwrapProxy(date);
104+
105+
expect(result).toBe(date);
106+
});
107+
108+
it("preserves RegExp instances", () => {
109+
const regex = /test/gi;
110+
const result = unwrapProxy(regex);
111+
112+
expect(result).toBe(regex);
113+
});
114+
115+
it("preserves Error instances", () => {
116+
const error = new Error("test");
117+
const result = unwrapProxy(error);
118+
119+
expect(result).toBe(error);
120+
});
121+
});
122+
});

packages/core/src/persist.ts

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,59 @@ export type Constructor<T = any> = {
1515
[PERSISTED_PROPERTIES]?: Set<string>;
1616
};
1717

18+
/**
19+
* Recursively unwraps proxy objects to get raw data for RPC serialization.
20+
* Handles circular references by tracking visited objects.
21+
* @internal
22+
*/
23+
export function unwrapProxy<T>(value: T, seen = new WeakMap<object, unknown>()): T {
24+
if (value === null || value === undefined || typeof value !== 'object') {
25+
return value;
26+
}
27+
if (value instanceof Date || value instanceof RegExp || value instanceof Error) {
28+
return value;
29+
}
30+
31+
// Handle circular references
32+
const cached = seen.get(value);
33+
if (cached !== undefined) {
34+
return cached as T;
35+
}
36+
37+
if (Array.isArray(value)) {
38+
const result: unknown[] = [];
39+
seen.set(value, result);
40+
for (let i = 0; i < value.length; i++) {
41+
result[i] = unwrapProxy(value[i], seen);
42+
}
43+
return result as T;
44+
}
45+
if (value instanceof Map) {
46+
const result = new Map();
47+
seen.set(value, result);
48+
for (const [k, v] of value) {
49+
result.set(k, unwrapProxy(v, seen));
50+
}
51+
return result as T;
52+
}
53+
if (value instanceof Set) {
54+
const result = new Set();
55+
seen.set(value, result);
56+
for (const v of value) {
57+
result.add(unwrapProxy(v, seen));
58+
}
59+
return result as T;
60+
}
61+
62+
const result = Object.create(null) as Record<string, unknown>;
63+
seen.set(value, result);
64+
for (const key of Object.keys(value)) {
65+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
66+
result[key] = unwrapProxy((value as Record<string, unknown>)[key], seen);
67+
}
68+
return result as T;
69+
}
70+
1871
/**
1972
* Creates a deep proxy for objects to track nested property changes
2073
* @param value The value to potentially proxy
@@ -57,13 +110,19 @@ function createDeepProxy(value: any, instance: any, propertyKey: string, trigger
57110
if (key === IS_PROXIED) return true;
58111

59112
// Handle special cases and built-in methods
60-
if (typeof key === 'symbol' ||
61-
key === 'toString' ||
62-
key === 'valueOf' ||
113+
if (typeof key === 'symbol' ||
114+
key === 'toString' ||
115+
key === 'valueOf' ||
63116
key === 'constructor' ||
64-
key === 'toJSON') {
117+
key === '__proto__' ||
118+
key === 'prototype') {
65119
return Reflect.get(target, key);
66120
}
121+
122+
// Provide toJSON to enable RPC serialization
123+
if (key === 'toJSON') {
124+
return () => unwrapProxy(target);
125+
}
67126

68127
try {
69128
// Check if the property exists
@@ -141,14 +200,17 @@ function createDeepProxy(value: any, instance: any, propertyKey: string, trigger
141200
const currentValue = Reflect.get(target, key);
142201

143202
// Handle different type transition scenarios
144-
if (currentValue !== null &&
145-
typeof currentValue === 'object' &&
146-
newValue !== null &&
147-
typeof newValue === 'object' &&
148-
!Array.isArray(currentValue) &&
203+
if (currentValue !== null &&
204+
typeof currentValue === 'object' &&
205+
newValue !== null &&
206+
typeof newValue === 'object' &&
207+
!Array.isArray(currentValue) &&
149208
!Array.isArray(newValue)) {
150209
// Case 1: Both values are objects - merge them instead of replacing
151-
Object.assign(currentValue, newValue);
210+
for (const k of Object.keys(newValue)) {
211+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
212+
(currentValue as Record<string, unknown>)[k] = (newValue as Record<string, unknown>)[k];
213+
}
152214
} else if (newValue !== null && typeof newValue === 'object' && !Object.isFrozen(newValue)) {
153215
// Case 2: New value is an object but current value is not (or doesn't exist)
154216
// Create a new proxied object

0 commit comments

Comments
 (0)