mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 03:54:41 +08:00
fix(core): reject prototype-polluting paths
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
// =============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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