mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad20d3ede |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
// =============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user