Skip to content

Commit adfd6af

Browse files
committed
Add createRecord helper for prototype-less objects
1 parent 3068006 commit adfd6af

6 files changed

Lines changed: 45 additions & 9 deletions

File tree

.changeset/cold-nails-wink.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"@evolu/common": patch
2+
3+
Add a typed helper `createRecord` for safely creating prototype-less
4+
`Record<K, V>` instances (via `Object.create(null)`). This prevents
5+
prototype pollution and accidental key collisions for object keys that come
6+
from external sources, like database column names.

packages/common/src/Object.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ type StringKeyOf<T> = Extract<keyof T, string>;
3131
*
3232
* ```ts
3333
* type UserId = string & { readonly __brand: "UserId" };
34-
* const users: Record<UserId, string> = {};
34+
* const users = createRecord<UserId, string>();
3535
* const entries = objectToEntries(users); // [UserId, string][]
3636
* ```
3737
*/
@@ -70,3 +70,22 @@ export const excludeProp = <T extends object, K extends keyof T>(
7070
const { [prop]: _, ...rest } = obj;
7171
return rest;
7272
};
73+
74+
/**
75+
* Creates a prototype-less object typed as `Record<K, V>`.
76+
*
77+
* Use this function when you need a plain record without a prototype chain
78+
* (e.g. when keys are controlled by external sources) to avoid prototype
79+
* pollution and accidental collisions with properties like `__proto__`.
80+
*
81+
* Example:
82+
*
83+
* ```ts
84+
* const values = createRecord<string, SqliteValue>();
85+
* values["__proto__"] = someValue; // safe, no prototype pollution
86+
* ```
87+
*/
88+
export const createRecord = <K extends string = string, V = unknown>(): Record<
89+
K,
90+
V
91+
> => Object.create(null) as Record<K, V>;

packages/common/src/local-first/Diff.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createRandomBytes } from "../Crypto.js";
2-
import { isPlainObject, ReadonlyRecord } from "../Object.js";
2+
import { createRecord, isPlainObject, ReadonlyRecord } from "../Object.js";
33
import { orderUint8Array } from "../Order.js";
44
import { SqliteValue } from "../Sqlite.js";
55
import { createId, String } from "../Type.js";
@@ -136,7 +136,7 @@ const parse = (obj: unknown): unknown => {
136136
const parseObject = (
137137
obj: ReadonlyRecord<string, unknown>,
138138
): ReadonlyRecord<string, unknown> => {
139-
const result = Object.create(null) as Record<string, unknown>;
139+
const result = createRecord();
140140
for (const key in obj) {
141141
result[key] = parse(obj[key]);
142142
}

packages/common/src/local-first/Protocol.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ import {
198198
} from "../Crypto.js";
199199
import { eqArrayNumber } from "../Eq.js";
200200
import { computeBalancedBuckets } from "../Number.js";
201-
import { objectToEntries } from "../Object.js";
201+
import { createRecord, objectToEntries } from "../Object.js";
202202
import { err, ok, Result } from "../Result.js";
203203
import { SqliteValue } from "../Sqlite.js";
204204
import {
@@ -221,8 +221,8 @@ import {
221221
} from "../Type.js";
222222
import { Predicate } from "../Types.js";
223223
import {
224-
OwnerError,
225224
Owner,
225+
OwnerError,
226226
OwnerId,
227227
OwnerIdBytes,
228228
ownerIdToOwnerIdBytes,
@@ -1816,7 +1816,7 @@ export const decryptAndDecodeDbChange =
18161816
const id = decodeId(buffer);
18171817

18181818
const length = decodeLength(buffer);
1819-
const values = Object.create(null) as Record<string, SqliteValue>;
1819+
const values = createRecord<string, SqliteValue>();
18201820

18211821
for (let i = 0; i < length; i++) {
18221822
const column = decodeString(buffer);

packages/common/src/local-first/Sync.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
import { eqArrayNumber } from "../Eq.js";
1616
import { createTransferableError, TransferableError } from "../Error.js";
1717
import { constFalse, constTrue } from "../Function.js";
18-
import { objectToEntries } from "../Object.js";
18+
import { createRecord, objectToEntries } from "../Object.js";
1919
import { RandomDep } from "../Random.js";
2020
import { createResources } from "../Resources.js";
2121
import { err, ok, Result } from "../Result.js";
@@ -581,7 +581,7 @@ const createClientStorage =
581581
assert(rows.length > 0, "Rows must not be empty");
582582

583583
const { table, id } = rows[0];
584-
const values: Record<string, SqliteValue> = {};
584+
const values = createRecord<string, SqliteValue>();
585585
let isInsert = false;
586586
let isDelete: boolean | null = null;
587587

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import { expect, test } from "vitest";
2-
import { isPlainObject } from "../src/Object.js";
2+
import { createRecord, isPlainObject } from "../src/Object.js";
33

44
test("isPlainObject", () => {
55
expect(isPlainObject({})).toBe(true);
66
expect(isPlainObject(new Date())).toBe(false);
77
expect(isPlainObject([])).toBe(false);
88
expect(isPlainObject(null)).toBe(false);
99
});
10+
11+
test("createRecord", () => {
12+
const values = createRecord<string, number>();
13+
values.__proto__ = 123;
14+
15+
expect(values.__proto__).toBe(123);
16+
17+
// Ensure Object.prototype was not changed
18+
const protoValue = (Object.prototype as any).__proto__;
19+
expect((Object.prototype as any).__proto__).toBe(protoValue);
20+
});

0 commit comments

Comments
 (0)