Compare commits

...
Author SHA1 Message Date
Chris Tate 4ad20d3ede fix(core): reject prototype-polluting paths 2026-09-13 20:40:58 -05:00
7 changed files with 142 additions and 4 deletions
@@ -451,6 +451,8 @@ const value = getByPath(state, '/user/name'); // "Alice"
setByPath(state, '/user/email', 'alice@example.com');
```
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
+35 -1
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { createStateStore, flattenToPointers } from "./state-store";
import {
createStateStore,
flattenToPointers,
immutableSetByPath,
} from "./state-store";
describe("createStateStore", () => {
it("creates a store with initial state", () => {
@@ -135,6 +139,36 @@ describe("createStateStore", () => {
store.set("/x", 2);
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
});
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("immutableSetByPath", () => {
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("flattenToPointers", () => {
+7
View File
@@ -1,5 +1,6 @@
import {
getByPath,
isSafeJsonPointerPath,
parseJsonPointer,
type StateModel,
type StateStore,
@@ -15,6 +16,8 @@ export function immutableSetByPath(
path: string,
value: unknown,
): StateModel {
if (!isSafeJsonPointerPath(path)) return root;
const segments = parseJsonPointer(path);
if (segments.length === 0) return root;
@@ -72,6 +75,7 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
},
set(path: string, value: unknown): void {
if (!isSafeJsonPointerPath(path)) return;
if (getByPath(state, path) === value) return;
state = immutableSetByPath(state, path, value);
notify();
@@ -81,6 +85,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) {
next = immutableSetByPath(next, path, value);
changed = true;
@@ -137,6 +142,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;
config.setSnapshot(immutableSetByPath(current, path, value));
@@ -146,6 +152,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) {
next = immutableSetByPath(next, path, value);
changed = true;
+61
View File
@@ -215,6 +215,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)
// =============================================================================
+33 -3
View File
@@ -280,6 +280,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)
*/
@@ -289,6 +309,7 @@ export function getByPath(obj: unknown, path: string): unknown {
}
const segments = parseJsonPointer(path);
if (hasBlockedJsonPointerToken(segments)) return undefined;
let current: unknown = obj;
@@ -362,7 +383,7 @@ export function setByPath(
): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -412,7 +433,7 @@ export function addByPath(
): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -458,7 +479,7 @@ export function addByPath(
export function removeByPath(obj: Record<string, unknown>, path: string): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -617,6 +638,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: