Files
homarr/packages/custom-widgets/src/jsx/bindings.ts
T

156 lines
6.3 KiB
TypeScript

import { createSafeCallable } from "./safe-bindings";
import { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
export const CUSTOM_JSX_DATA_LIMITS = Object.freeze({
depth: 32,
nodes: 50_000,
stringLength: 200_000,
});
interface SanitizeTask {
value: unknown;
depth: number;
assign(value: unknown): void;
}
function sanitizeData(value: unknown): unknown {
let result: unknown;
let nodes = 0;
const seen = new WeakSet<object>();
const tasks: SanitizeTask[] = [{ value, depth: 0, assign: (sanitized) => (result = sanitized) }];
while (tasks.length > 0) {
const task = tasks.pop();
if (!task) break;
if (++nodes > CUSTOM_JSX_DATA_LIMITS.nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
if (task.depth > CUSTOM_JSX_DATA_LIMITS.depth) {
throw new SafeJsxBudgetError(`Response data exceeded the depth limit (${CUSTOM_JSX_DATA_LIMITS.depth})`);
}
if (typeof task.value === "string" && task.value.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
if (task.value === null || typeof task.value !== "object") {
task.assign(task.value);
continue;
}
if (seen.has(task.value)) throw new SafeJsxError("Response data must not contain circular or repeated objects");
seen.add(task.value);
if (Array.isArray(task.value)) {
if (task.value.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe = Array.from<unknown>({ length: task.value.length });
task.assign(safe);
for (let index = task.value.length - 1; index >= 0; index -= 1) {
if (!Object.hasOwn(task.value, index)) continue;
tasks.push({
value: task.value[index],
depth: task.depth + 1,
assign: (sanitized) => (safe[index] = sanitized),
});
}
continue;
}
const entries = Object.entries(task.value);
if (entries.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe: Record<string, unknown> = Object.create(null);
task.assign(safe);
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, child] = entries[index] as [string, unknown];
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
if (key.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
tasks.push({
value: child,
depth: task.depth + 1,
assign: (sanitized) => (safe[key] = sanitized),
});
}
}
return result;
}
const safeMath = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
round: createSafeCallable((value: number) => Math.round(value)),
floor: createSafeCallable((value: number) => Math.floor(value)),
ceil: createSafeCallable((value: number) => Math.ceil(value)),
abs: createSafeCallable((value: number) => Math.abs(value)),
min: createSafeCallable((...values: number[]) => Math.min(...values)),
max: createSafeCallable((...values: number[]) => Math.max(...values)),
pow: createSafeCallable((base: number, exponent: number) => Math.pow(base, exponent)),
sqrt: createSafeCallable((value: number) => Math.sqrt(value)),
PI: Math.PI,
}),
);
const safeDate = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
now: createSafeCallable(() => Date.now()),
create: createSafeCallable((value?: string | number) =>
value === undefined ? Date.now() : new Date(value).getTime(),
),
toISOString: createSafeCallable((value: string | number) => new Date(value).toISOString()),
toLocaleDateString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleDateString(locale ?? "en-US"),
),
toLocaleTimeString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleTimeString(locale ?? "en-US"),
),
getTime: createSafeCallable((value: string | number) => new Date(value).getTime()),
getYear: createSafeCallable((value: string | number) => new Date(value).getFullYear()),
getMonth: createSafeCallable((value: string | number) => new Date(value).getMonth()),
getDay: createSafeCallable((value: string | number) => new Date(value).getUTCDay()),
}),
);
export function createCustomJsxBindings(apiData: unknown) {
const safeJson = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
stringify: createSafeCallable((value: unknown) => JSON.stringify(value)),
}),
);
const safeArray = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
isArray: createSafeCallable(Array.isArray),
from: createSafeCallable((value: ArrayLike<unknown> | Iterable<unknown>) => Array.from(value).slice(0, 2_000)),
}),
);
const safeObject = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
keys: createSafeCallable(Object.keys),
values: createSafeCallable(Object.values),
entries: createSafeCallable(Object.entries),
}),
);
return {
data: sanitizeData(apiData),
String: createSafeCallable((value: unknown) => String(value)),
Number: createSafeCallable((value: unknown) => Number(value)),
Boolean: createSafeCallable((value: unknown) => Boolean(value)),
Math: safeMath,
JSON: safeJson,
Array: safeArray,
Object: safeObject,
Date: safeDate,
parseInt: createSafeCallable((value: string, radix?: number) => parseInt(value, radix)),
parseFloat: createSafeCallable((value: string) => parseFloat(value)),
encodeURIComponent: createSafeCallable((value: string) => encodeURIComponent(value)),
decodeURIComponent: createSafeCallable((value: string) => decodeURIComponent(value)),
isNaN: createSafeCallable((value: unknown) => Number.isNaN(Number(value))),
isFinite: createSafeCallable((value: unknown) => Number.isFinite(Number(value))),
};
}