fix(core): reject prototype-polluting paths

This commit is contained in:
Chris Tate
2026-09-13 21:57:45 -05:00
parent 10847bb185
commit de6a9bdbc0
7 changed files with 155 additions and 1 deletions
@@ -462,6 +462,10 @@ Invalid array reads return `undefined`, and invalid writes or removals are
no-ops. A `-` token is not valid for reads, removals, or intermediate array
segments.
For prototype safety, path utilities, state stores, and SpecStream patches
reject JSON Pointer paths containing `__proto__`, `constructor`, or `prototype`
tokens. Compound patches validate both `path` and `from` before mutating data.
### resolveDynamicValue
```typescript
+2
View File
@@ -126,6 +126,8 @@ SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/ht
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
For prototype safety, JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. This applies to both `path` and `from` in compound patches.
### Low-Level Utilities
```typescript
+42
View File
@@ -79,6 +79,18 @@ describe("immutableSetByPath", () => {
expect(inferred).toEqual({ records: { "01": { name: "literal" } } });
expect(appended).toEqual({ items: ["appended"] });
});
it.each(["__proto__", "constructor", "prototype"])(
"rejects %s without changing snapshot identity or its prototype",
(token) => {
const state = { safe: true };
const result = immutableSetByPath(state, `/${token}/polluted`, "value");
expect(result).toBe(state);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result).toEqual({ safe: true });
},
);
});
describe("createStateStore", () => {
@@ -261,6 +273,22 @@ describe("createStateStore", () => {
expect(store.getSnapshot()).toEqual({ items: [{ name: "first" }] });
});
it.each(["__proto__", "constructor", "prototype"])(
"rejects %s state paths without publishing a snapshot",
(token) => {
const store = createStateStore({ safe: true });
const listener = vi.fn();
const snapshot = store.getSnapshot();
store.subscribe(listener);
store.set(`/${token}/polluted`, "value");
store.update({ [`/safe/${token}/polluted`]: "value" });
expect(store.getSnapshot()).toBe(snapshot);
expect(listener).not.toHaveBeenCalled();
},
);
});
describe("createStoreAdapter", () => {
@@ -323,6 +351,20 @@ describe("createStoreAdapter", () => {
expect(harness.setSnapshot).toHaveBeenCalledTimes(1);
expect(harness.getSnapshot()).toEqual({ items: [{ name: "first" }] });
});
it.each(["__proto__", "constructor", "prototype"])(
"rejects %s state paths without writing a snapshot",
(token) => {
const harness = createAdapterHarness({ safe: true });
const snapshot = harness.getSnapshot();
harness.store.set(`/${token}/polluted`, "value");
harness.store.update({ [`/safe/${token}/polluted`]: "value" });
expect(harness.getSnapshot()).toBe(snapshot);
expect(harness.setSnapshot).not.toHaveBeenCalled();
},
);
});
describe("flattenToPointers", () => {
+7
View File
@@ -1,5 +1,6 @@
import {
getByPath,
isSafeJsonPointerPath,
parseJsonPointer,
type StateModel,
type StateStore,
@@ -73,6 +74,8 @@ export function immutableSetByPath(
path: string,
value: unknown,
): StateModel {
if (!isSafeJsonPointerPath(path)) return root;
const segments = parseJsonPointer(path);
if (segments.length === 0) return root;
if (!canImmutableSetBySegments(root, segments)) return root;
@@ -143,6 +146,7 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
},
set(path: string, value: unknown): void {
if (!isSafeJsonPointerPath(path)) return;
if (getByPath(state, path) === value) return;
const next = immutableSetByPath(state, path, value);
if (next === state) return;
@@ -154,6 +158,7 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
let changed = false;
let next = state;
for (const [path, value] of Object.entries(updates)) {
if (!isSafeJsonPointerPath(path)) continue;
if (getByPath(next, path) !== value) {
const updated = immutableSetByPath(next, path, value);
if (updated !== next) {
@@ -213,6 +218,7 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
},
set(path: string, value: unknown): void {
if (!isSafeJsonPointerPath(path)) return;
const current = config.getSnapshot();
if (getByPath(current, path) === value) return;
const next = immutableSetByPath(current, path, value);
@@ -224,6 +230,7 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
let next = config.getSnapshot();
let changed = false;
for (const [path, value] of Object.entries(updates)) {
if (!isSafeJsonPointerPath(path)) continue;
if (getByPath(next, path) !== value) {
const updated = immutableSetByPath(next, path, value);
if (updated !== next) {
+61
View File
@@ -335,6 +335,67 @@ describe("JSON Pointer escaping (RFC 6901)", () => {
});
});
// =============================================================================
// JSON Pointer prototype safety
// =============================================================================
describe("JSON Pointer prototype safety", () => {
const blockedTokens = ["__proto__", "constructor", "prototype"];
it.each(blockedTokens)("rejects %s in path utility writes", (token) => {
const pollutionKey = "__json_render_pollution_probe__";
const data: Record<string, unknown> = {};
try {
setByPath(data, `/${token}/${pollutionKey}`, "set");
addByPath(data, `/safe/${token}/${pollutionKey}`, "add");
expect(data).toEqual({});
expect(Object.prototype).not.toHaveProperty(pollutionKey);
} finally {
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
}
});
it("does not read or remove values through Object.prototype", () => {
const pollutionKey = "__json_render_inherited_probe__";
(Object.prototype as Record<string, unknown>)[pollutionKey] = "keep";
try {
expect(getByPath({}, `/__proto__/${pollutionKey}`)).toBeUndefined();
removeByPath({}, `/__proto__/${pollutionKey}`);
expect((Object.prototype as Record<string, unknown>)[pollutionKey]).toBe(
"keep",
);
} finally {
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
}
});
it.each(blockedTokens)(
"rejects compound patches containing %s before mutation",
(token) => {
const destination: Record<string, unknown> = { source: "one" };
applySpecStreamPatch(destination, {
op: "move",
from: "/source",
path: `/${token}/moved`,
});
const source: Record<string, unknown> = {};
applySpecStreamPatch(source, {
op: "copy",
from: `/${token}/value`,
path: "/copy",
});
expect(destination).toEqual({ source: "one" });
expect(source).toEqual({});
expect(Object.hasOwn(source, "copy")).toBe(false);
},
);
});
// =============================================================================
// addByPath (RFC 6902 "add" semantics)
// =============================================================================
+37 -1
View File
@@ -281,6 +281,26 @@ export function parseJsonPointer(path: string): string[] {
return raw.map(unescapeJsonPointer);
}
const blockedJsonPointerTokens = new Set([
"__proto__",
"constructor",
"prototype",
]);
/**
* Reject tokens that can traverse or modify JavaScript prototype chains.
* Validation happens after JSON Pointer unescaping so encoded paths cannot
* bypass it.
*/
function hasBlockedJsonPointerToken(segments: string[]): boolean {
return segments.some((segment) => blockedJsonPointerTokens.has(segment));
}
/** @internal Shared by JSON Pointer-based state stores. */
export function isSafeJsonPointerPath(path: string): boolean {
return !hasBlockedJsonPointerToken(parseJsonPointer(path));
}
/**
* Get a value from an object by JSON Pointer path (RFC 6901)
*/
@@ -308,6 +328,9 @@ function readByPath(obj: unknown, path: string): PathReadResult {
}
const segments = parseJsonPointer(path);
if (hasBlockedJsonPointerToken(segments)) {
return { valid: false, exists: false };
}
let current: unknown = obj;
@@ -396,6 +419,8 @@ function canWriteBySegments(
root: Record<string, unknown>,
segments: string[],
): boolean {
if (hasBlockedJsonPointerToken(segments)) return false;
let current: unknown = root;
for (let i = 0; i < segments.length - 1; i++) {
@@ -557,7 +582,9 @@ function removeByPathInternal(
): boolean {
const segments = parseJsonPointer(path);
if (segments.length === 0) return false;
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) {
return false;
}
let current: Record<string, unknown> | unknown[] = obj;
@@ -850,6 +877,15 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
obj: T,
patch: SpecStreamLine,
): T {
if (!isSafeJsonPointerPath(patch.path)) return obj;
if (
(patch.op === "move" || patch.op === "copy") &&
patch.from !== undefined &&
!isSafeJsonPointerPath(patch.from)
) {
return obj;
}
switch (patch.op) {
case "add":
addByPath(obj, patch.path, patch.value);
+2
View File
@@ -77,6 +77,8 @@ const { result, newPatches } = compiler.push(chunk);
const finalSpec = compiler.getResult();
```
JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. For `move` and `copy`, this applies to both `path` and `from`.
## Dynamic Prop Expressions
Any prop value can be a dynamic expression resolved at render time: