fix(custom-widgets): harden v2 authoring
This commit is contained in:
171 files changed
+20311
-50527
No files matched your search
@@ -37,10 +37,16 @@ export function containsEscapingCallback(node: AstNode): boolean {
|
||||
if (node.type === "ArrowFunctionExpression") return true;
|
||||
if (node.type === "CallExpression") {
|
||||
const callee = nodeOf(node.callee);
|
||||
const arguments_ = nodesOf(node.arguments);
|
||||
if (callee?.type === "ArrowFunctionExpression") {
|
||||
if (arguments_.length > 0) return true;
|
||||
const body = nodeOf(callee.body);
|
||||
return body ? containsEscapingCallbackChildren(body) : false;
|
||||
}
|
||||
if (callee && containsEscapingCallback(callee)) return true;
|
||||
const property = callee?.type === "MemberExpression" ? nodeOf(callee.property) : null;
|
||||
const method = property?.type === "Identifier" ? String(property.name) : staticPropertyName(property);
|
||||
return nodesOf(node.arguments).some((argument) => {
|
||||
return arguments_.some((argument) => {
|
||||
if (argument.type !== "ArrowFunctionExpression" || !method || !callbackCollectionMethods.has(method)) {
|
||||
return containsEscapingCallback(argument);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
customJsxSupportedPropsByName,
|
||||
} from "../core/component-registry";
|
||||
import type { AstNode } from "./analyzer-ast";
|
||||
import { containsEscapingCallback, nodeOf, nodesOf } from "./analyzer-ast";
|
||||
import {
|
||||
closestCustomJsxComponentName,
|
||||
customJsxTagName,
|
||||
isRestrictedRecursiveListPath,
|
||||
isSafeLiteralCustomJsxUrl,
|
||||
} from "./analyzer-language";
|
||||
import { CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
|
||||
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
|
||||
|
||||
interface AnalyzerJsxContext {
|
||||
add(node: AstNode, message: string, severity?: "error" | "warning"): void;
|
||||
visit(node: AstNode, depth: number, bindings: ReadonlySet<string>): void;
|
||||
visitArrow(node: AstNode, depth: number, bindings: ReadonlySet<string>): void;
|
||||
}
|
||||
|
||||
export function analyzeCustomJsxElement(
|
||||
node: AstNode,
|
||||
depth: number,
|
||||
bindings: ReadonlySet<string>,
|
||||
context: AnalyzerJsxContext,
|
||||
): void {
|
||||
const opening = nodeOf(node.openingElement);
|
||||
if (!opening) {
|
||||
context.add(node, "Invalid JSX element");
|
||||
return;
|
||||
}
|
||||
const nameNode = nodeOf(opening.name);
|
||||
const name = nameNode ? customJsxTagName(nameNode) : null;
|
||||
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
|
||||
if (descriptor?.safety === "denied") {
|
||||
context.add(
|
||||
opening,
|
||||
`BLOCKED_CAPABILITY: '${name}' is not available${descriptor.reason ? ` because it ${descriptor.reason.toLowerCase()}` : ""}`,
|
||||
);
|
||||
} else if (!descriptor) {
|
||||
const suggestion = name ? closestCustomJsxComponentName(name) : undefined;
|
||||
context.add(
|
||||
opening,
|
||||
name
|
||||
? `UNKNOWN_COMPONENT: '${name}' is not available${suggestion ? `. Did you mean '${suggestion}'?` : ""}`
|
||||
: "UNKNOWN_COMPONENT: Invalid JSX component name",
|
||||
);
|
||||
}
|
||||
|
||||
const attributeNames = new Set<string>();
|
||||
let hasAttributeSpread = false;
|
||||
for (const attribute of nodesOf(opening.attributes)) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
hasAttributeSpread = true;
|
||||
const argument = nodeOf(attribute.argument);
|
||||
if (argument) context.visit(argument, depth + 1, bindings);
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") {
|
||||
context.add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
|
||||
continue;
|
||||
}
|
||||
const attributeNameNode = nodeOf(attribute.name);
|
||||
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
|
||||
attributeNames.add(attributeName);
|
||||
const componentBlockedProp = descriptor?.blockedProps.find(({ name: propName }) => propName === attributeName);
|
||||
if (componentBlockedProp) {
|
||||
context.add(
|
||||
attribute,
|
||||
`BLOCKED_CAPABILITY: Prop '${attributeName}' on '${name}' is not allowed because ${componentBlockedProp.reason.toLowerCase()}`,
|
||||
);
|
||||
} else if (isBlockedCustomJsxProp(attributeName)) {
|
||||
context.add(attribute, `BLOCKED_CAPABILITY: Prop '${attributeName}' is not allowed`);
|
||||
} else if (name && attributeName !== "bind" && !customJsxSupportedPropsByName.get(name)?.has(attributeName)) {
|
||||
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
} else if (name && attributeName === "bind" && !customJsxBindableComponentNames.has(name)) {
|
||||
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
|
||||
}
|
||||
analyzeAttributeValue(name, attributeName, attribute, depth, bindings, context);
|
||||
}
|
||||
|
||||
if (name === "RecursiveList") analyzeRecursiveList(node, opening, attributeNames, hasAttributeSpread, context);
|
||||
nodesOf(node.children).forEach((child) => {
|
||||
if (name === "RecursiveList" && child.type === "JSXExpressionContainer") {
|
||||
const expression = nodeOf(child.expression);
|
||||
if (expression?.type === "ArrowFunctionExpression") {
|
||||
context.visitArrow(expression, depth + 1, bindings);
|
||||
return;
|
||||
}
|
||||
}
|
||||
context.visit(child, depth + 1, bindings);
|
||||
});
|
||||
}
|
||||
|
||||
function analyzeAttributeValue(
|
||||
componentName: string | null,
|
||||
attributeName: string,
|
||||
attribute: AstNode,
|
||||
depth: number,
|
||||
bindings: ReadonlySet<string>,
|
||||
context: AnalyzerJsxContext,
|
||||
): void {
|
||||
const value = nodeOf(attribute.value);
|
||||
const literalValue =
|
||||
value?.type === "Literal"
|
||||
? value
|
||||
: value?.type === "JSXExpressionContainer" && nodeOf(value.expression)?.type === "Literal"
|
||||
? nodeOf(value.expression)
|
||||
: null;
|
||||
if (componentName === "RecursiveList" && literalValue?.type === "Literal") {
|
||||
if (
|
||||
["childrenPath", "keyPath"].includes(attributeName) &&
|
||||
(typeof literalValue.value !== "string" || !isRestrictedRecursiveListPath(literalValue.value))
|
||||
) {
|
||||
context.add(attribute, `INVALID_PROP_VALUE: '${attributeName}' must be a safe dotted property path`);
|
||||
}
|
||||
if (
|
||||
attributeName === "maxDepth" &&
|
||||
(typeof literalValue.value !== "number" || literalValue.value < 1 || literalValue.value > 32)
|
||||
) {
|
||||
context.add(attribute, "INVALID_PROP_VALUE: 'maxDepth' must be between 1 and 32");
|
||||
}
|
||||
if (
|
||||
attributeName === "maxNodes" &&
|
||||
(typeof literalValue.value !== "number" || literalValue.value < 1 || literalValue.value > 2_000)
|
||||
) {
|
||||
context.add(attribute, "INVALID_PROP_VALUE: 'maxNodes' must be between 1 and 2000");
|
||||
}
|
||||
}
|
||||
if (componentName && literalValue?.type === "Literal") {
|
||||
const reason = getInvalidCustomJsxPropValueReason(componentName, attributeName, literalValue.value);
|
||||
if (reason) context.add(attribute, `INVALID_PROP_VALUE: ${reason}`);
|
||||
}
|
||||
if (
|
||||
CUSTOM_JSX_URL_PROPS.has(attributeName) &&
|
||||
value?.type === "Literal" &&
|
||||
typeof value.value === "string" &&
|
||||
!isSafeLiteralCustomJsxUrl(value.value)
|
||||
) {
|
||||
context.add(attribute, `INVALID_PROP_VALUE: '${attributeName}' contains an unsafe URL`);
|
||||
}
|
||||
if (value?.type !== "JSXExpressionContainer") return;
|
||||
const expression = nodeOf(value.expression);
|
||||
if (expression && containsEscapingCallback(expression)) {
|
||||
context.add(attribute, `BLOCKED_CAPABILITY: Callback prop '${attributeName}' is not allowed`);
|
||||
} else if (expression && expression.type !== "JSXEmptyExpression") {
|
||||
context.visit(expression, depth + 1, bindings);
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeRecursiveList(
|
||||
node: AstNode,
|
||||
opening: AstNode,
|
||||
attributeNames: ReadonlySet<string>,
|
||||
hasAttributeSpread: boolean,
|
||||
context: AnalyzerJsxContext,
|
||||
): void {
|
||||
for (const requiredProp of ["data", "childrenPath", "keyPath"]) {
|
||||
if (!hasAttributeSpread && !attributeNames.has(requiredProp)) {
|
||||
context.add(opening, `INVALID_PROP_VALUE: RecursiveList requires '${requiredProp}'`);
|
||||
}
|
||||
}
|
||||
const meaningfulChildren = nodesOf(node.children).filter(
|
||||
(child) => child.type !== "JSXText" || String(child.value ?? "").trim().length > 0,
|
||||
);
|
||||
const templateExpression =
|
||||
meaningfulChildren.length === 1 && meaningfulChildren[0]?.type === "JSXExpressionContainer"
|
||||
? nodeOf(meaningfulChildren[0].expression)
|
||||
: null;
|
||||
if (templateExpression?.type !== "ArrowFunctionExpression") {
|
||||
context.add(node, "RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList requires exactly one inline arrow child");
|
||||
return;
|
||||
}
|
||||
const parameters = nodesOf(templateExpression.params);
|
||||
if (parameters.length === 0 || parameters.length > 2) {
|
||||
context.add(
|
||||
templateExpression,
|
||||
"RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList child accepts node and optional metadata parameters",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { enabledCustomJsxComponents } from "../core/component-registry";
|
||||
import type { AstNode } from "./analyzer-ast";
|
||||
import { nodeOf, staticPropertyName } from "./analyzer-ast";
|
||||
import { CUSTOM_JSX_BLOCKED_PROPERTIES, normalizeCustomJsxProperty } from "./policy";
|
||||
|
||||
export const ROOT_BINDINGS = new Set([
|
||||
"Array",
|
||||
"Boolean",
|
||||
"Date",
|
||||
"Infinity",
|
||||
"JSON",
|
||||
"Math",
|
||||
"NaN",
|
||||
"Number",
|
||||
"Object",
|
||||
"String",
|
||||
"data",
|
||||
"options",
|
||||
"inputs",
|
||||
"status",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
"isFinite",
|
||||
"isNaN",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
"undefined",
|
||||
]);
|
||||
|
||||
export const RESERVED_LOCAL_BINDINGS = new Set(["data", "status", "options", "inputs"]);
|
||||
const callableRootBindings = new Set([
|
||||
"Boolean",
|
||||
"Number",
|
||||
"String",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
"isFinite",
|
||||
"isNaN",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
]);
|
||||
const callableRootMembers: Readonly<Record<string, ReadonlySet<string>>> = {
|
||||
Array: new Set(["from", "isArray"]),
|
||||
Date: new Set([
|
||||
"create",
|
||||
"getDay",
|
||||
"getMonth",
|
||||
"getTime",
|
||||
"getYear",
|
||||
"now",
|
||||
"toISOString",
|
||||
"toLocaleDateString",
|
||||
"toLocaleTimeString",
|
||||
]),
|
||||
JSON: new Set(["stringify"]),
|
||||
Math: new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sqrt"]),
|
||||
Object: new Set(["entries", "keys", "values"]),
|
||||
};
|
||||
const enabledComponentNames = enabledCustomJsxComponents.map((component) => component.name);
|
||||
|
||||
export function closestCustomJsxComponentName(value: string): string | undefined {
|
||||
let closest: { name: string; distance: number } | undefined;
|
||||
for (const name of enabledComponentNames) {
|
||||
const distance = editDistance(value.toLowerCase(), name.toLowerCase());
|
||||
if (!closest || distance < closest.distance) closest = { name, distance };
|
||||
}
|
||||
return closest && closest.distance <= Math.max(2, Math.floor(value.length / 3)) ? closest.name : undefined;
|
||||
}
|
||||
|
||||
export function customJsxTagName(node: AstNode): string | null {
|
||||
if (node.type === "JSXIdentifier") return String(node.name ?? "");
|
||||
if (node.type !== "JSXMemberExpression") return null;
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
const left = object ? customJsxTagName(object) : null;
|
||||
const right = property ? customJsxTagName(property) : null;
|
||||
return left && right ? `${left}.${right}` : null;
|
||||
}
|
||||
|
||||
export function isSafeLiteralCustomJsxUrl(value: string): boolean {
|
||||
if (value.startsWith("#") || (value.startsWith("/") && !value.startsWith("//"))) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRestrictedRecursiveListPath(value: string): boolean {
|
||||
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/u.test(value)) return false;
|
||||
return value.split(".").every((segment) => !CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(segment)));
|
||||
}
|
||||
|
||||
export function isStaticallyCallableBinding(node: AstNode): boolean {
|
||||
if (node.type === "Identifier") return callableRootBindings.has(String(node.name));
|
||||
if (node.type !== "MemberExpression") return false;
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
if (object?.type !== "Identifier") return false;
|
||||
const propertyName = node.computed ? staticPropertyName(property) : String(property?.name ?? "");
|
||||
return Boolean(propertyName && callableRootMembers[String(object.name)]?.has(propertyName));
|
||||
}
|
||||
|
||||
function editDistance(left: string, right: string): number {
|
||||
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
||||
const current = [leftIndex];
|
||||
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
||||
current[rightIndex] = Math.min(
|
||||
(current[rightIndex - 1] ?? 0) + 1,
|
||||
(previous[rightIndex] ?? 0) + 1,
|
||||
(previous[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
return previous[right.length] ?? Math.max(left.length, right.length);
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import {
|
||||
customJsxBindableComponentNames,
|
||||
customJsxComponentByName,
|
||||
customJsxSupportedPropsByName,
|
||||
enabledCustomJsxComponents,
|
||||
} from "../core/component-registry";
|
||||
import {
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES,
|
||||
CUSTOM_JSX_LIMITS,
|
||||
CUSTOM_JSX_URL_PROPS,
|
||||
isBlockedCustomJsxProp,
|
||||
isBlockedCustomJsxLexicalBinding,
|
||||
normalizeCustomJsxProperty,
|
||||
} from "./policy";
|
||||
import type { AstNode } from "./analyzer-ast";
|
||||
import { containsEscapingCallback, nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast";
|
||||
import { nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast";
|
||||
import { analyzeCustomJsxElement } from "./analyzer-jsx";
|
||||
import { isStaticallyCallableBinding, RESERVED_LOCAL_BINDINGS, ROOT_BINDINGS } from "./analyzer-language";
|
||||
import {
|
||||
CUSTOM_JSX_BINARY_OPERATORS,
|
||||
CUSTOM_JSX_CALLBACK_METHODS,
|
||||
CUSTOM_JSX_SAFE_VALUE_METHODS,
|
||||
} from "./safe-language-policy";
|
||||
|
||||
export interface CustomJsxTemplateDiagnostic {
|
||||
severity: "error" | "warning";
|
||||
@@ -26,66 +26,6 @@ export interface CustomJsxTemplateDiagnostic {
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
const enabledComponentNames = enabledCustomJsxComponents.map((component) => component.name);
|
||||
|
||||
function closestComponentName(value: string): string | undefined {
|
||||
let closest: { name: string; distance: number } | undefined;
|
||||
for (const name of enabledComponentNames) {
|
||||
const distance = editDistance(value.toLowerCase(), name.toLowerCase());
|
||||
if (!closest || distance < closest.distance) closest = { name, distance };
|
||||
}
|
||||
return closest && closest.distance <= Math.max(2, Math.floor(value.length / 3)) ? closest.name : undefined;
|
||||
}
|
||||
|
||||
function editDistance(left: string, right: string): number {
|
||||
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
||||
const current = [leftIndex];
|
||||
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
||||
current[rightIndex] = Math.min(
|
||||
(current[rightIndex - 1] ?? 0) + 1,
|
||||
(previous[rightIndex] ?? 0) + 1,
|
||||
(previous[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous.splice(0, previous.length, ...current);
|
||||
}
|
||||
return previous[right.length] ?? Math.max(left.length, right.length);
|
||||
}
|
||||
|
||||
const rootBindings = new Set([
|
||||
"Array",
|
||||
"Boolean",
|
||||
"Date",
|
||||
"Infinity",
|
||||
"JSON",
|
||||
"Math",
|
||||
"NaN",
|
||||
"Number",
|
||||
"Object",
|
||||
"String",
|
||||
"data",
|
||||
"options",
|
||||
"inputs",
|
||||
"status",
|
||||
"decodeURIComponent",
|
||||
"encodeURIComponent",
|
||||
"isFinite",
|
||||
"isNaN",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
"undefined",
|
||||
]);
|
||||
|
||||
function tagName(node: AstNode): string | null {
|
||||
if (node.type === "JSXIdentifier") return String(node.name ?? "");
|
||||
if (node.type !== "JSXMemberExpression") return null;
|
||||
const object = nodeOf(node.object);
|
||||
const property = nodeOf(node.property);
|
||||
const left = object ? tagName(object) : null;
|
||||
const right = property ? tagName(property) : null;
|
||||
return left && right ? `${left}.${right}` : null;
|
||||
}
|
||||
|
||||
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
|
||||
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
|
||||
@@ -112,7 +52,116 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations;
|
||||
};
|
||||
|
||||
const visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
|
||||
let visit: (node: AstNode, depth: number, bindings: ReadonlySet<string>) => void;
|
||||
|
||||
const visitArrow = (node: AstNode, depth: number, bindings: ReadonlySet<string>) => {
|
||||
if (node.async || node.generator) {
|
||||
add(node, "CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported");
|
||||
return;
|
||||
}
|
||||
const callbackBindings = new Set(bindings);
|
||||
const parameterNames = new Set<string>();
|
||||
for (const parameter of nodesOf(node.params)) {
|
||||
if (parameter.type !== "Identifier") {
|
||||
add(parameter, "INVALID_LOCAL_DECLARATION: Callback parameters must be identifiers");
|
||||
continue;
|
||||
}
|
||||
const name = String(parameter.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
add(parameter, `INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`);
|
||||
continue;
|
||||
}
|
||||
if (RESERVED_LOCAL_BINDINGS.has(name)) {
|
||||
add(parameter, `RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
continue;
|
||||
}
|
||||
if (parameterNames.has(name)) {
|
||||
add(parameter, `DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
continue;
|
||||
}
|
||||
parameterNames.add(name);
|
||||
callbackBindings.add(name);
|
||||
}
|
||||
const body = nodeOf(node.body);
|
||||
if (!body) return;
|
||||
if (body.type === "BlockStatement") visitSafeBlock(body, depth + 1, callbackBindings);
|
||||
else visit(body, depth + 1, callbackBindings);
|
||||
};
|
||||
|
||||
const visitSafeBlock = (block: AstNode, depth: number, bindings: ReadonlySet<string>) => {
|
||||
if (!checkBudget(block, depth)) return;
|
||||
const statements = nodesOf(block.body);
|
||||
const finalStatement = statements.at(-1);
|
||||
if (statements.length === 0 || finalStatement?.type !== "ReturnStatement" || !nodeOf(finalStatement.argument)) {
|
||||
add(block, "BLOCK_REQUIRES_FINAL_RETURN: A safe block must end with exactly one value-returning return");
|
||||
}
|
||||
if (statements.length < 2) {
|
||||
add(block, "INVALID_LOCAL_DECLARATION: A safe block requires at least one const before its final return");
|
||||
}
|
||||
|
||||
let scopedBindings = new Set(bindings);
|
||||
const localNames = new Set<string>();
|
||||
statements.forEach((statement, statementIndex) => {
|
||||
if (!checkBudget(statement, depth + 1)) return;
|
||||
const isFinal = statementIndex === statements.length - 1;
|
||||
if (statement.type === "ReturnStatement") {
|
||||
if (!isFinal) {
|
||||
add(statement, "BLOCK_REQUIRES_FINAL_RETURN: Return is only allowed as the final statement");
|
||||
return;
|
||||
}
|
||||
const argument = nodeOf(statement.argument);
|
||||
if (argument) visit(argument, depth + 1, scopedBindings);
|
||||
return;
|
||||
}
|
||||
if (statement.type !== "VariableDeclaration") {
|
||||
add(statement, `UNSUPPORTED_BLOCK_STATEMENT: '${statement.type}' is not allowed in a safe block`);
|
||||
return;
|
||||
}
|
||||
if (statement.kind !== "const") {
|
||||
add(statement, "INVALID_LOCAL_DECLARATION: Only immutable const declarations are allowed");
|
||||
return;
|
||||
}
|
||||
for (const declaration of nodesOf(statement.declarations)) {
|
||||
if (!checkBudget(declaration, depth + 2)) continue;
|
||||
const identifier = nodeOf(declaration.id);
|
||||
if (declaration.type !== "VariableDeclarator" || identifier?.type !== "Identifier") {
|
||||
add(declaration, "INVALID_LOCAL_DECLARATION: Local declarations require a simple identifier");
|
||||
continue;
|
||||
}
|
||||
const name = String(identifier.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
add(identifier, `INVALID_LOCAL_DECLARATION: '${name}' is not a safe local binding name`);
|
||||
continue;
|
||||
}
|
||||
if (RESERVED_LOCAL_BINDINGS.has(name)) {
|
||||
add(identifier, `RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
continue;
|
||||
}
|
||||
if (localNames.has(name)) {
|
||||
add(identifier, `DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
continue;
|
||||
}
|
||||
const initializer = nodeOf(declaration.init);
|
||||
if (!initializer) {
|
||||
add(declaration, `LOCAL_BINDING_REQUIRES_INITIALIZER: '${name}' requires an initializer`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
initializer.type === "ArrowFunctionExpression" ||
|
||||
initializer.type === "FunctionExpression" ||
|
||||
isStaticallyCallableBinding(initializer)
|
||||
) {
|
||||
add(initializer, "CALLBACK_VALUE_NOT_ALLOWED: Functions cannot be stored in local bindings");
|
||||
continue;
|
||||
}
|
||||
visit(initializer, depth + 1, scopedBindings);
|
||||
localNames.add(name);
|
||||
scopedBindings = new Set(scopedBindings).add(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
|
||||
if (!checkBudget(node, depth)) return;
|
||||
|
||||
switch (node.type) {
|
||||
@@ -128,67 +177,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
|
||||
return;
|
||||
case "JSXElement": {
|
||||
const opening = nodeOf(node.openingElement);
|
||||
if (!opening) {
|
||||
add(node, "Invalid JSX element");
|
||||
return;
|
||||
}
|
||||
const nameNode = nodeOf(opening.name);
|
||||
const name = nameNode ? tagName(nameNode) : null;
|
||||
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
|
||||
if (descriptor?.safety === "denied") {
|
||||
add(
|
||||
opening,
|
||||
`BLOCKED_CAPABILITY: '${name}' is not available${descriptor.reason ? ` because it ${descriptor.reason.toLowerCase()}` : ""}`,
|
||||
);
|
||||
} else if (!descriptor) {
|
||||
const suggestion = name ? closestComponentName(name) : undefined;
|
||||
add(
|
||||
opening,
|
||||
name
|
||||
? `UNKNOWN_COMPONENT: '${name}' is not available${suggestion ? `. Did you mean '${suggestion}'?` : ""}`
|
||||
: "UNKNOWN_COMPONENT: Invalid JSX component name",
|
||||
);
|
||||
}
|
||||
|
||||
for (const attribute of nodesOf(opening.attributes)) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const argument = nodeOf(attribute.argument);
|
||||
if (argument) visit(argument, depth + 1, bindings);
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") {
|
||||
add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
|
||||
continue;
|
||||
}
|
||||
const attributeNameNode = nodeOf(attribute.name);
|
||||
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
|
||||
if (isBlockedCustomJsxProp(attributeName)) {
|
||||
add(attribute, `BLOCKED_CAPABILITY: Prop '${attributeName}' is not allowed`);
|
||||
} else if (name && attributeName !== "bind" && !customJsxSupportedPropsByName.get(name)?.has(attributeName)) {
|
||||
add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
|
||||
} else if (name && attributeName === "bind" && !customJsxBindableComponentNames.has(name)) {
|
||||
add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
|
||||
}
|
||||
const value = nodeOf(attribute.value);
|
||||
if (
|
||||
CUSTOM_JSX_URL_PROPS.has(attributeName) &&
|
||||
value?.type === "Literal" &&
|
||||
typeof value.value === "string" &&
|
||||
!isSafeLiteralUrl(value.value)
|
||||
) {
|
||||
add(attribute, `INVALID_PROP_VALUE: '${attributeName}' contains an unsafe URL`);
|
||||
}
|
||||
if (value?.type === "JSXExpressionContainer") {
|
||||
const expression = nodeOf(value.expression);
|
||||
if (expression && containsEscapingCallback(expression)) {
|
||||
add(attribute, `BLOCKED_CAPABILITY: Callback prop '${attributeName}' is not allowed`);
|
||||
} else if (expression && expression.type !== "JSXEmptyExpression") {
|
||||
visit(expression, depth + 1, bindings);
|
||||
}
|
||||
}
|
||||
}
|
||||
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
|
||||
analyzeCustomJsxElement(node, depth, bindings, { add, visit, visitArrow });
|
||||
return;
|
||||
}
|
||||
case "JSXText":
|
||||
@@ -248,6 +237,9 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
return;
|
||||
case "BinaryExpression":
|
||||
case "LogicalExpression": {
|
||||
if (node.type === "BinaryExpression" && !CUSTOM_JSX_BINARY_OPERATORS.has(String(node.operator))) {
|
||||
add(node, `Binary operator '${String(node.operator)}' is not supported`);
|
||||
}
|
||||
const left = nodeOf(node.left);
|
||||
const right = nodeOf(node.right);
|
||||
if (left) visit(left, depth + 1, bindings);
|
||||
@@ -275,27 +267,70 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
}
|
||||
case "CallExpression": {
|
||||
const callee = nodeOf(node.callee);
|
||||
const arguments_ = nodesOf(node.arguments);
|
||||
if (callee?.type === "ArrowFunctionExpression") {
|
||||
if (nodesOf(callee.params).length > 0) {
|
||||
add(callee, "CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions cannot declare parameters");
|
||||
}
|
||||
if (arguments_.length > 0) {
|
||||
add(node, "CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions must be called without arguments");
|
||||
}
|
||||
visitArrow(callee, depth + 1, bindings);
|
||||
arguments_.forEach((argument) => visit(argument, depth + 1, bindings));
|
||||
return;
|
||||
}
|
||||
if (callee) visit(callee, depth + 1, bindings);
|
||||
nodesOf(node.arguments).forEach((argument) => {
|
||||
const property = callee?.type === "MemberExpression" ? nodeOf(callee.property) : null;
|
||||
const method = property?.type === "Identifier" ? String(property.name) : staticPropertyName(property);
|
||||
if (
|
||||
callee &&
|
||||
!isStaticallyCallableBinding(callee) &&
|
||||
(callee.type !== "MemberExpression" || !method || !CUSTOM_JSX_SAFE_VALUE_METHODS.has(method))
|
||||
) {
|
||||
add(callee, "CALL_TARGET_NOT_ALLOWED: Only documented safe helpers and value methods can be called");
|
||||
}
|
||||
if (method && CUSTOM_JSX_CALLBACK_METHODS.has(method)) {
|
||||
const callback = arguments_[0];
|
||||
if (!callback) {
|
||||
add(node, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires a callback`);
|
||||
} else if (["reduce", "sort"].includes(method) && callback.type !== "ArrowFunctionExpression") {
|
||||
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow callback`);
|
||||
} else if (callback.type !== "ArrowFunctionExpression" && !isStaticallyCallableBinding(callback)) {
|
||||
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`);
|
||||
}
|
||||
}
|
||||
arguments_.forEach((argument, argumentIndex) => {
|
||||
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
|
||||
else visit(argument, depth + 1, bindings);
|
||||
else if (
|
||||
argument.type === "ArrowFunctionExpression" &&
|
||||
argumentIndex === 0 &&
|
||||
method &&
|
||||
CUSTOM_JSX_CALLBACK_METHODS.has(method)
|
||||
) {
|
||||
visitArrow(argument, depth + 1, bindings);
|
||||
} else if (argument.type === "ArrowFunctionExpression") {
|
||||
add(argument, "CALLBACK_VALUE_NOT_ALLOWED: Callback arguments are only allowed in the first callback slot");
|
||||
} else visit(argument, depth + 1, bindings);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "ArrowFunctionExpression": {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
add(node, "Only synchronous expression-bodied arrow callbacks are supported");
|
||||
return;
|
||||
}
|
||||
const callbackBindings = new Set(bindings);
|
||||
for (const parameter of nodesOf(node.params)) {
|
||||
if (parameter.type !== "Identifier") add(parameter, "Callback parameters must be identifiers");
|
||||
else callbackBindings.add(String(parameter.name));
|
||||
}
|
||||
const body = nodeOf(node.body);
|
||||
if (body) visit(body, depth + 1, callbackBindings);
|
||||
add(
|
||||
node,
|
||||
"CALLBACK_VALUE_NOT_ALLOWED: Callbacks are only allowed in safe collection methods and trusted slots",
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "BlockStatement":
|
||||
add(node, "UNSUPPORTED_BLOCK_STATEMENT: Blocks are only allowed as safe callback or IIFE bodies");
|
||||
return;
|
||||
case "AssignmentExpression":
|
||||
case "UpdateExpression":
|
||||
case "NewExpression":
|
||||
case "AwaitExpression":
|
||||
case "YieldExpression":
|
||||
add(node, `UNSUPPORTED_BLOCK_STATEMENT: '${node.type}' is not allowed in safe expressions`);
|
||||
return;
|
||||
case "TemplateLiteral":
|
||||
nodesOf(node.expressions).forEach((expression) => visit(expression, depth + 1, bindings));
|
||||
return;
|
||||
@@ -315,27 +350,32 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
sourceType: "module",
|
||||
locations: true,
|
||||
}) as unknown as AstNode;
|
||||
visit(program, 0, rootBindings);
|
||||
visit(program, 0, ROOT_BINDINGS);
|
||||
} catch (error) {
|
||||
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
|
||||
const parseIndex = Math.max(0, (parseError.pos ?? 2) - 2);
|
||||
const missingInitializer = [...template.matchAll(/(?:\bconst|,)\s+([A-Za-z_$][\w$]*)\s*;/gu)].find((match) => {
|
||||
const semicolon = (match.index ?? 0) + match[0].length - 1;
|
||||
return Math.abs(semicolon - parseIndex) <= 1;
|
||||
});
|
||||
const duplicateCandidate = /Identifier '([^']+)' has already been declared/iu.exec(parseError.message);
|
||||
const duplicateBinding = duplicateCandidate;
|
||||
const message = missingInitializer
|
||||
? `LOCAL_BINDING_REQUIRES_INITIALIZER: '${missingInitializer[1]}' requires an initializer`
|
||||
: duplicateBinding
|
||||
? `DUPLICATE_LOCAL_BINDING: '${duplicateBinding[1]}' is already declared`
|
||||
: parseError.message;
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message: parseError.message,
|
||||
message,
|
||||
index: Math.max(0, (parseError.pos ?? 2) - 2),
|
||||
line: parseError.loc?.line ?? 1,
|
||||
column: Math.max(1, (parseError.loc?.column ?? 2) - 1),
|
||||
column: Math.max(
|
||||
1,
|
||||
(parseError.loc?.column ?? 0) + (parseError.loc?.line === 1 || parseError.loc?.line === undefined ? -1 : 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function isSafeLiteralUrl(value: string) {
|
||||
if (value.startsWith("#") || (value.startsWith("/") && !value.startsWith("//"))) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
|
||||
|
||||
export interface JsxEmitterContext {
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
budget: Budget;
|
||||
warnings: Set<string>;
|
||||
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
|
||||
createCallback(node: AstNode, environment: Environment): InterpreterCallback;
|
||||
renderCallback(callback: InterpreterCallback, args: unknown[], depth: number): ReactNode;
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export * from "./policy";
|
||||
export * from "./safe-bindings";
|
||||
export * from "./bindings";
|
||||
export * from "./runtime-components";
|
||||
export * from "./recursive-list";
|
||||
export * from "./interactive-components";
|
||||
export { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
asNode,
|
||||
asNodeArray,
|
||||
Environment,
|
||||
INTERPRETER_CALLBACK,
|
||||
isInterpreterCallback,
|
||||
SafeJsxError,
|
||||
} from "./interpreter-foundation";
|
||||
import type { AstNode, Budget, InterpreterCallback } from "./interpreter-foundation";
|
||||
import { isBlockedCustomJsxLexicalBinding } from "./policy";
|
||||
|
||||
interface InterpreterCallbackContext {
|
||||
budget: Budget;
|
||||
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
|
||||
}
|
||||
|
||||
const reservedLocalBindings = new Set(["data", "status", "options", "inputs"]);
|
||||
|
||||
export function createInterpreterCallback(node: AstNode, environment: Environment): InterpreterCallback {
|
||||
if (node.async || node.generator) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Async and generator callbacks are not supported");
|
||||
}
|
||||
const parameterNames = new Set<string>();
|
||||
const params = asNodeArray(node.params, "callback parameters").map((param) => {
|
||||
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
|
||||
const name = String(param.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
throw new SafeJsxError(`INVALID_LOCAL_DECLARATION: '${name}' is not a safe callback parameter name`);
|
||||
}
|
||||
if (reservedLocalBindings.has(name)) {
|
||||
throw new SafeJsxError(`RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
}
|
||||
if (parameterNames.has(name)) {
|
||||
throw new SafeJsxError(`DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
}
|
||||
parameterNames.add(name);
|
||||
return name;
|
||||
});
|
||||
return {
|
||||
kind: INTERPRETER_CALLBACK,
|
||||
params,
|
||||
body: asNode(node.body, "callback body"),
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
export function runInterpreterCallback(
|
||||
callback: InterpreterCallback,
|
||||
args: unknown[],
|
||||
depth: number,
|
||||
context: InterpreterCallbackContext,
|
||||
): unknown {
|
||||
const values: Record<string, unknown> = Object.create(null);
|
||||
callback.params.forEach((name, index) => {
|
||||
values[name] = args[index];
|
||||
});
|
||||
const environment = new Environment(values, callback.environment);
|
||||
return callback.body.type === "BlockStatement"
|
||||
? executeSafeBlock(callback.body, environment, depth + 1, context)
|
||||
: context.evaluate(callback.body, environment, depth + 1);
|
||||
}
|
||||
|
||||
function executeSafeBlock(
|
||||
block: AstNode,
|
||||
environment: Environment,
|
||||
depth: number,
|
||||
context: InterpreterCallbackContext,
|
||||
): unknown {
|
||||
context.budget.operation(depth);
|
||||
const statements = asNodeArray(block.body, "safe block statements");
|
||||
const finalStatement = statements.at(-1);
|
||||
if (statements.length === 0 || finalStatement?.type !== "ReturnStatement" || !finalStatement.argument) {
|
||||
throw new SafeJsxError(
|
||||
"BLOCK_REQUIRES_FINAL_RETURN: A safe block must end with exactly one value-returning return",
|
||||
);
|
||||
}
|
||||
if (statements.length < 2) {
|
||||
throw new SafeJsxError(
|
||||
"INVALID_LOCAL_DECLARATION: A safe block requires at least one const before its final return",
|
||||
);
|
||||
}
|
||||
|
||||
let scopedEnvironment = environment;
|
||||
const localNames = new Set<string>();
|
||||
for (let statementIndex = 0; statementIndex < statements.length; statementIndex += 1) {
|
||||
const statement = statements[statementIndex];
|
||||
if (!statement) continue;
|
||||
context.budget.operation(depth + 1);
|
||||
const isFinal = statementIndex === statements.length - 1;
|
||||
if (statement.type === "ReturnStatement") {
|
||||
if (!isFinal) {
|
||||
throw new SafeJsxError("BLOCK_REQUIRES_FINAL_RETURN: Return is only allowed as the final statement");
|
||||
}
|
||||
return context.evaluate(asNode(statement.argument, "safe block return value"), scopedEnvironment, depth + 1);
|
||||
}
|
||||
if (statement.type !== "VariableDeclaration") {
|
||||
throw new SafeJsxError(`UNSUPPORTED_BLOCK_STATEMENT: '${statement.type}' is not allowed in a safe block`);
|
||||
}
|
||||
if (statement.kind !== "const") {
|
||||
throw new SafeJsxError("INVALID_LOCAL_DECLARATION: Only immutable const declarations are allowed");
|
||||
}
|
||||
for (const declaration of asNodeArray(statement.declarations, "local declarations")) {
|
||||
context.budget.operation(depth + 2);
|
||||
const identifier = asNode(declaration.id, "local identifier");
|
||||
if (declaration.type !== "VariableDeclarator" || identifier.type !== "Identifier") {
|
||||
throw new SafeJsxError("INVALID_LOCAL_DECLARATION: Local declarations require a simple identifier");
|
||||
}
|
||||
const name = String(identifier.name);
|
||||
if (isBlockedCustomJsxLexicalBinding(name)) {
|
||||
throw new SafeJsxError(`INVALID_LOCAL_DECLARATION: '${name}' is not a safe local binding name`);
|
||||
}
|
||||
if (reservedLocalBindings.has(name)) {
|
||||
throw new SafeJsxError(`RESERVED_LOCAL_BINDING: '${name}' cannot be shadowed`);
|
||||
}
|
||||
if (localNames.has(name)) {
|
||||
throw new SafeJsxError(`DUPLICATE_LOCAL_BINDING: '${name}' is already declared`);
|
||||
}
|
||||
const initializer = asNode(declaration.init, `initializer for '${name}'`);
|
||||
if (initializer.type === "ArrowFunctionExpression" || initializer.type === "FunctionExpression") {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Functions cannot be stored in local bindings");
|
||||
}
|
||||
const value = context.evaluate(initializer, scopedEnvironment, depth + 1);
|
||||
if (typeof value === "function" || isInterpreterCallback(value)) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Functions cannot be stored in local bindings");
|
||||
}
|
||||
localNames.add(name);
|
||||
scopedEnvironment = new Environment({ [name]: value }, scopedEnvironment);
|
||||
}
|
||||
}
|
||||
throw new SafeJsxError("BLOCK_REQUIRES_FINAL_RETURN: Safe block did not return a value");
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export class SafeJsxError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SafeJsxBudgetError extends SafeJsxError {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SafeJsxBudgetError";
|
||||
}
|
||||
}
|
||||
|
||||
export class Environment {
|
||||
public constructor(
|
||||
private readonly values: Readonly<Record<string, unknown>>,
|
||||
@@ -61,22 +68,22 @@ export class Budget {
|
||||
public constructor(private readonly limits: EvaluationBudgets) {}
|
||||
public operation(depth: number): void {
|
||||
if (depth > this.limits.maxAstDepth)
|
||||
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
|
||||
if (++this.operations > this.limits.maxOperations)
|
||||
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
|
||||
}
|
||||
public collection(count = 1): void {
|
||||
this.collectionItems += count;
|
||||
if (this.collectionItems > this.limits.maxCollectionItems)
|
||||
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
|
||||
}
|
||||
public rendered(): void {
|
||||
if (++this.renderedNodes > this.limits.maxRenderedNodes)
|
||||
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
|
||||
}
|
||||
public string(value: string): string {
|
||||
if (value.length > this.limits.maxStringLength)
|
||||
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
|
||||
throw new SafeJsxBudgetError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import type { AstNode } from "./interpreter-foundation";
|
||||
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
|
||||
import { CUSTOM_JSX_LIMITS } from "./policy";
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
|
||||
export function parseCustomJsxTemplate(template: string): AstNode {
|
||||
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
|
||||
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
|
||||
}
|
||||
let program: AstNode;
|
||||
try {
|
||||
program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowAwaitOutsideFunction: false,
|
||||
allowReturnOutsideFunction: false,
|
||||
}) as unknown as AstNode;
|
||||
} catch (error) {
|
||||
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
|
||||
}
|
||||
const body = asNodeArray(program.body, "program body");
|
||||
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
|
||||
throw new SafeJsxError("Template must contain JSX only");
|
||||
}
|
||||
return asNode(body[0].expression, "template expression");
|
||||
}
|
||||
|
||||
export function normalizeCustomJsxText(value: string): string {
|
||||
const lines = value.replace(/\r/g, "").split("\n");
|
||||
let result = "";
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let line = lines[index] ?? "";
|
||||
line = line.replace(/\t/g, " ");
|
||||
if (index !== 0) line = line.replace(/^\s+/, "");
|
||||
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
|
||||
if (!line) continue;
|
||||
if (result) result += " ";
|
||||
result += line;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,25 +1,18 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { Parser } from "acorn";
|
||||
import jsx from "acorn-jsx";
|
||||
|
||||
import { callCollectionMethod } from "./collection-methods";
|
||||
import { staticPropertyName } from "./analyzer-ast";
|
||||
import type { JsxEmitterContext } from "./emitter-context";
|
||||
import { createInterpreterCallback, runInterpreterCallback } from "./interpreter-callbacks";
|
||||
import { emitJsxElement, emitJsxFragment } from "./jsx-emitter";
|
||||
import type { JsxEmitterContext } from "./jsx-emitter";
|
||||
import { isSafeCallable } from "./safe-bindings";
|
||||
import {
|
||||
asNode,
|
||||
asNodeArray,
|
||||
Budget,
|
||||
DEFAULT_BUDGETS,
|
||||
Environment,
|
||||
INTERPRETER_CALLBACK,
|
||||
SafeJsxError,
|
||||
} from "./interpreter-foundation";
|
||||
import { asNode, asNodeArray, Budget, DEFAULT_BUDGETS, Environment, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets } from "./interpreter-foundation";
|
||||
import { normalizeCustomJsxText, parseCustomJsxTemplate } from "./interpreter-parser";
|
||||
import { normalizedProperty, ownProperty } from "./safe-properties";
|
||||
import { CUSTOM_JSX_LIMITS } from "./policy";
|
||||
import { CUSTOM_JSX_CALLBACK_METHODS } from "./safe-language-policy";
|
||||
|
||||
export { SafeJsxError } from "./interpreter-foundation";
|
||||
export { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
|
||||
export type { SafeJsxBudgets } from "./interpreter-foundation";
|
||||
export { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
@@ -35,23 +28,6 @@ export interface SafeJsxRenderResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const JsxParser = Parser.extend(jsx());
|
||||
|
||||
function jsxText(value: string): string {
|
||||
const lines = value.replace(/\r/g, "").split("\n");
|
||||
let result = "";
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let line = lines[index] ?? "";
|
||||
line = line.replace(/\t/g, " ");
|
||||
if (index !== 0) line = line.replace(/^\s+/, "");
|
||||
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
|
||||
if (!line) continue;
|
||||
if (result) result += " ";
|
||||
result += line;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class Interpreter {
|
||||
private readonly budget: Budget;
|
||||
private readonly warnings = new Set<string>();
|
||||
@@ -81,7 +57,7 @@ class Interpreter {
|
||||
case "JSXFragment":
|
||||
return this.evaluateJsxFragment(node, environment, depth + 1);
|
||||
case "JSXText": {
|
||||
const text = this.budget.string(jsxText(String(node.value ?? "")));
|
||||
const text = this.budget.string(normalizeCustomJsxText(String(node.value ?? "")));
|
||||
if (text) this.budget.rendered();
|
||||
return text || null;
|
||||
}
|
||||
@@ -110,11 +86,19 @@ class Interpreter {
|
||||
case "CallExpression":
|
||||
return this.evaluateCall(node, environment, depth + 1);
|
||||
case "ArrowFunctionExpression":
|
||||
return this.createCallback(node, environment);
|
||||
throw new SafeJsxError(
|
||||
"CALLBACK_VALUE_NOT_ALLOWED: Callbacks are only allowed in safe collection methods and trusted slots",
|
||||
);
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node, environment, depth + 1);
|
||||
case "ChainExpression":
|
||||
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
|
||||
case "AssignmentExpression":
|
||||
case "UpdateExpression":
|
||||
case "NewExpression":
|
||||
case "AwaitExpression":
|
||||
case "YieldExpression":
|
||||
throw new SafeJsxError(`UNSUPPORTED_BLOCK_STATEMENT: '${node.type}' is not allowed in safe expressions`);
|
||||
default:
|
||||
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
|
||||
}
|
||||
@@ -134,8 +118,8 @@ class Interpreter {
|
||||
budget: this.budget,
|
||||
warnings: this.warnings,
|
||||
evaluate: (node, environment, depth) => this.evaluate(node, environment, depth),
|
||||
renderCallback: (callback, args) =>
|
||||
new Interpreter(this.components, callback.environment, this.limits).runCallback(callback, args, 0) as ReactNode,
|
||||
createCallback: (node, environment) => this.createCallback(node, environment),
|
||||
renderCallback: (callback, args, depth) => this.runCallback(callback, args, depth) as ReactNode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,11 +261,35 @@ class Interpreter {
|
||||
|
||||
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
|
||||
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
|
||||
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
|
||||
const callee = asNode(node.callee, "call target");
|
||||
const argumentNodes = asNodeArray(node.arguments, "call arguments");
|
||||
if (callee.type === "ArrowFunctionExpression") {
|
||||
if (asNodeArray(callee.params, "inline derived-value parameters").length > 0) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions cannot declare parameters");
|
||||
}
|
||||
if (argumentNodes.length > 0) {
|
||||
throw new SafeJsxError(
|
||||
"CALLBACK_VALUE_NOT_ALLOWED: Inline derived-value functions must be called without arguments",
|
||||
);
|
||||
}
|
||||
return this.runCallback(this.createCallback(callee, environment), [], depth + 1);
|
||||
}
|
||||
const callbackMethod =
|
||||
callee.type === "MemberExpression"
|
||||
? callee.computed
|
||||
? staticPropertyName(asNode(callee.property, "method property"))
|
||||
: normalizedProperty(asNode(callee.property, "method property").name)
|
||||
: null;
|
||||
const args = argumentNodes.map((argument) => {
|
||||
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
|
||||
if (argument.type === "ArrowFunctionExpression") {
|
||||
if (!callbackMethod || !CUSTOM_JSX_CALLBACK_METHODS.has(callbackMethod)) {
|
||||
throw new SafeJsxError("CALLBACK_VALUE_NOT_ALLOWED: Callback argument is not allowed for this call");
|
||||
}
|
||||
return this.createCallback(argument, environment);
|
||||
}
|
||||
return this.evaluate(argument, environment, depth + 1);
|
||||
});
|
||||
const callee = asNode(node.callee, "call target");
|
||||
|
||||
if (callee.type === "MemberExpression") {
|
||||
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
|
||||
@@ -317,27 +325,14 @@ class Interpreter {
|
||||
}
|
||||
|
||||
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
|
||||
if (node.async || node.generator || node.expression === false) {
|
||||
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
|
||||
}
|
||||
const params = asNodeArray(node.params, "callback parameters").map((param) => {
|
||||
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
|
||||
return normalizedProperty(param.name);
|
||||
});
|
||||
return {
|
||||
kind: INTERPRETER_CALLBACK,
|
||||
params,
|
||||
body: asNode(node.body, "callback body"),
|
||||
environment,
|
||||
};
|
||||
return createInterpreterCallback(node, environment);
|
||||
}
|
||||
|
||||
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
|
||||
const values: Record<string, unknown> = Object.create(null);
|
||||
callback.params.forEach((name, index) => {
|
||||
values[name] = args[index];
|
||||
return runInterpreterCallback(callback, args, depth, {
|
||||
budget: this.budget,
|
||||
evaluate: (node, environment, callbackDepth) => this.evaluate(node, environment, callbackDepth),
|
||||
});
|
||||
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
|
||||
@@ -355,33 +350,11 @@ class Interpreter {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTemplate(template: string): AstNode {
|
||||
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
|
||||
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
|
||||
}
|
||||
let program: AstNode;
|
||||
try {
|
||||
program = JsxParser.parse(`<>${template}</>`, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
allowAwaitOutsideFunction: false,
|
||||
allowReturnOutsideFunction: false,
|
||||
}) as unknown as AstNode;
|
||||
} catch (error) {
|
||||
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
|
||||
}
|
||||
const body = asNodeArray(program.body, "program body");
|
||||
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
|
||||
throw new SafeJsxError("Template must contain JSX only");
|
||||
}
|
||||
return asNode(body[0].expression, "template expression");
|
||||
}
|
||||
|
||||
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
|
||||
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
|
||||
for (const [name, value] of Object.entries(limits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
|
||||
}
|
||||
const root = parseTemplate(template);
|
||||
const root = parseCustomJsxTemplate(template);
|
||||
return new Interpreter(components, new Environment(bindings), limits).render(root);
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createElement, Fragment } from "react";
|
||||
|
||||
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
|
||||
import type { AstNode, Environment } from "./interpreter-foundation";
|
||||
import type { JsxEmitterContext } from "./emitter-context";
|
||||
import { emitRecursiveList } from "./recursive-list-emitter";
|
||||
import { diagnoseCustomJsxProps, normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
|
||||
import { CUSTOM_JSX_BLOCKED_TAGS } from "./policy";
|
||||
|
||||
export interface JsxEmitterContext {
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
budget: Budget;
|
||||
warnings: Set<string>;
|
||||
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
|
||||
renderCallback(callback: InterpreterCallback, args: unknown[]): ReactNode;
|
||||
}
|
||||
|
||||
export function emitJsxFragment(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
@@ -44,6 +38,7 @@ export function emitJsxElement(
|
||||
context.warnings.add(`Unknown or unavailable component: ${tag}`);
|
||||
return null;
|
||||
}
|
||||
if (tag === "RecursiveList") return emitRecursiveList(node, environment, depth, component, context);
|
||||
const rawProps: Record<string, unknown> = {};
|
||||
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
|
||||
@@ -87,19 +87,33 @@ export const CUSTOM_JSX_BLOCKED_STYLE_KEYS: ReadonlySet<string> = new Set([
|
||||
"zIndex",
|
||||
]);
|
||||
|
||||
const CUSTOM_JSX_BLOCKED_POSITION_PROPS: ReadonlySet<string> = new Set([
|
||||
"bottom",
|
||||
"inset",
|
||||
"left",
|
||||
"pos",
|
||||
"right",
|
||||
"top",
|
||||
"zIndex",
|
||||
]);
|
||||
|
||||
export function normalizeCustomJsxProperty(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isBlockedCustomJsxLexicalBinding(name: string): boolean {
|
||||
return CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(name));
|
||||
}
|
||||
|
||||
export function isBlockedCustomJsxProp(name: string): boolean {
|
||||
return (
|
||||
/^on/iu.test(name) ||
|
||||
name.startsWith("__") ||
|
||||
/(?:Ref|^ref[A-Z])/u.test(name) ||
|
||||
CUSTOM_JSX_BLOCKED_PROPS.has(name) ||
|
||||
CUSTOM_JSX_BLOCKED_STYLE_KEYS.has(name) ||
|
||||
CUSTOM_JSX_BLOCKED_POSITION_PROPS.has(name) ||
|
||||
(name !== "bind" && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(name)))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
import type { TreeNodeData } from "@mantine/core";
|
||||
|
||||
import { SafeJsxBudgetError } from "./interpreter-foundation";
|
||||
import {
|
||||
clampWithWarning,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_NODES,
|
||||
exceedsRecursiveListNodeLimit,
|
||||
formatPath,
|
||||
HARD_MAX_DEPTH,
|
||||
HARD_MAX_NODES,
|
||||
isObject,
|
||||
parseRestrictedPath,
|
||||
primitiveKey,
|
||||
readRestrictedPath,
|
||||
} from "./recursive-list-helpers";
|
||||
import type { RecursiveListBuildOptions, RecursiveListMetadata } from "./recursive-list-types";
|
||||
|
||||
const OMITTED_LABEL = "Additional levels omitted";
|
||||
|
||||
export function buildTrustedRecursiveList(options: RecursiveListBuildOptions): TreeNodeData[] {
|
||||
const maxDepth = clampWithWarning(
|
||||
options.maxDepth,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
1,
|
||||
HARD_MAX_DEPTH,
|
||||
"maxDepth",
|
||||
options.warnings,
|
||||
);
|
||||
const maxNodes = clampWithWarning(
|
||||
options.maxNodes,
|
||||
DEFAULT_MAX_NODES,
|
||||
1,
|
||||
HARD_MAX_NODES,
|
||||
"maxNodes",
|
||||
options.warnings,
|
||||
);
|
||||
let childrenPath: string[];
|
||||
let keyPath: string[];
|
||||
try {
|
||||
childrenPath = parseRestrictedPath(options.childrenPath, "childrenPath");
|
||||
keyPath = parseRestrictedPath(options.keyPath, "keyPath");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.warnings.add(message);
|
||||
options.budget.operation(1);
|
||||
options.budget.collection();
|
||||
options.budget.rendered();
|
||||
return [diagnosticNode("diagnostic:path", message)];
|
||||
}
|
||||
const roots = Array.isArray(options.data) ? options.data : isObject(options.data) ? [options.data] : null;
|
||||
if (!roots) {
|
||||
options.warnings.add("RECURSIVE_LIST_INVALID_DATA: data must be an object or an array of objects");
|
||||
options.budget.operation(1);
|
||||
options.budget.collection();
|
||||
options.budget.rendered();
|
||||
return [diagnosticNode("diagnostic:root-data", "RecursiveList data must be an object or an array")];
|
||||
}
|
||||
|
||||
const willExceedNodeLimit = exceedsRecursiveListNodeLimit(roots, childrenPath, maxDepth, maxNodes);
|
||||
let renderedRowCount = 0;
|
||||
let diagnosticCount = 0;
|
||||
let limitSentinelRendered = false;
|
||||
const usedKeys = new Set<string>();
|
||||
const chargeRow = (depth: number) => {
|
||||
options.budget.operation(depth + 1);
|
||||
options.budget.collection();
|
||||
options.budget.rendered();
|
||||
renderedRowCount += 1;
|
||||
};
|
||||
const makeLimitDiagnostic = (path: readonly number[], depth: number): TreeNodeData | null => {
|
||||
if (limitSentinelRendered || renderedRowCount >= maxNodes) return null;
|
||||
options.warnings.add(`RECURSIVE_LIST_NODE_LIMIT: Rendering stopped after ${maxNodes} nodes`);
|
||||
chargeRow(depth);
|
||||
diagnosticCount += 1;
|
||||
limitSentinelRendered = true;
|
||||
return diagnosticNode(`diagnostic:${path.join(".")}:${diagnosticCount}`, OMITTED_LABEL);
|
||||
};
|
||||
const makeDiagnostic = (path: readonly number[], message: string, depth: number): TreeNodeData | null => {
|
||||
if (renderedRowCount >= maxNodes) return null;
|
||||
if (willExceedNodeLimit && renderedRowCount === maxNodes - 1) return makeLimitDiagnostic(path, depth);
|
||||
chargeRow(depth);
|
||||
diagnosticCount += 1;
|
||||
return diagnosticNode(`diagnostic:${path.join(".")}:${diagnosticCount}`, message);
|
||||
};
|
||||
|
||||
const visitSiblings = (
|
||||
values: readonly unknown[],
|
||||
depth: number,
|
||||
parentPath: readonly number[],
|
||||
ancestors: ReadonlySet<object>,
|
||||
): TreeNodeData[] => {
|
||||
const result: TreeNodeData[] = [];
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (renderedRowCount >= maxNodes) break;
|
||||
if (willExceedNodeLimit && renderedRowCount === maxNodes - 1) {
|
||||
const omitted = makeLimitDiagnostic(parentPath, depth);
|
||||
if (omitted) result.push(omitted);
|
||||
break;
|
||||
}
|
||||
const value = values[index];
|
||||
const path = [...parentPath, index];
|
||||
if (!isObject(value)) {
|
||||
options.warnings.add(`RECURSIVE_LIST_INVALID_NODE: Node at ${formatPath(path)} is not an object`);
|
||||
const invalid = makeDiagnostic(path, "Invalid tree node omitted", depth);
|
||||
if (invalid) result.push(invalid);
|
||||
continue;
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
options.warnings.add(`RECURSIVE_LIST_CYCLE: Cycle detected at ${formatPath(path)}`);
|
||||
const cycle = makeDiagnostic(path, OMITTED_LABEL, depth);
|
||||
if (cycle) result.push(cycle);
|
||||
continue;
|
||||
}
|
||||
|
||||
chargeRow(depth);
|
||||
const rawKey = readRestrictedPath(value, keyPath);
|
||||
const preferredKey = primitiveKey(rawKey);
|
||||
const namespacedPreferredKey = preferredKey === null ? null : `key:${typeof rawKey}:${preferredKey}`;
|
||||
let key = namespacedPreferredKey ?? `path:${path.join(".")}`;
|
||||
if (namespacedPreferredKey === null) {
|
||||
options.warnings.add(`RECURSIVE_LIST_MISSING_KEY: Node at ${formatPath(path)} uses a stable path key`);
|
||||
} else if (usedKeys.has(namespacedPreferredKey)) {
|
||||
key = `path:${path.join(".")}`;
|
||||
options.warnings.add(`RECURSIVE_LIST_DUPLICATE_KEY: '${preferredKey}' uses a stable path key`);
|
||||
}
|
||||
usedKeys.add(key);
|
||||
|
||||
const rawChildren = readRestrictedPath(value, childrenPath);
|
||||
const childValues = rawChildren === undefined || rawChildren === null ? [] : rawChildren;
|
||||
const validChildren = Array.isArray(childValues);
|
||||
const childCount = validChildren ? childValues.length : 0;
|
||||
const metadata: RecursiveListMetadata = Object.freeze({
|
||||
depth,
|
||||
index,
|
||||
path: Object.freeze(path) as unknown as number[],
|
||||
key,
|
||||
hasChildren: childCount > 0,
|
||||
childCount,
|
||||
isLast: index === values.length - 1,
|
||||
});
|
||||
const nextAncestors = new Set(ancestors).add(value);
|
||||
const children: TreeNodeData[] = [];
|
||||
if (!validChildren) {
|
||||
options.warnings.add(
|
||||
`RECURSIVE_LIST_INVALID_CHILDREN: ${options.childrenPath} at ${formatPath(path)} must be an array`,
|
||||
);
|
||||
const invalidChildren = makeDiagnostic(path, `Expected ${options.childrenPath} to be an array`, depth + 1);
|
||||
if (invalidChildren) children.push(invalidChildren);
|
||||
} else if (childCount > 0 && depth + 1 >= maxDepth) {
|
||||
options.warnings.add(`RECURSIVE_LIST_DEPTH_LIMIT: Rendering stopped at depth ${maxDepth}`);
|
||||
const omitted = makeDiagnostic(path, OMITTED_LABEL, depth + 1);
|
||||
if (omitted) children.push(omitted);
|
||||
} else if (childCount > 0) {
|
||||
children.push(...visitSiblings(childValues, depth + 1, path, nextAncestors));
|
||||
}
|
||||
|
||||
let label: ReactNode;
|
||||
try {
|
||||
label = options.render(value, metadata, depth + 1);
|
||||
} catch (error) {
|
||||
if (error instanceof SafeJsxBudgetError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.warnings.add(`RECURSIVE_LIST_BRANCH_ERROR: ${formatPath(path)}: ${message}`);
|
||||
label = (
|
||||
<Text size="xs" c="red">
|
||||
Unable to render this branch: {message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
result.push({
|
||||
value: key,
|
||||
label,
|
||||
...(children.length > 0 ? { children } : {}),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return visitSiblings(roots, 0, [], new Set());
|
||||
}
|
||||
|
||||
function diagnosticNode(value: string, message: string): TreeNodeData {
|
||||
return {
|
||||
value,
|
||||
label: (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{message}
|
||||
</Text>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { buildTrustedRecursiveList } from "./recursive-list";
|
||||
import { asNode, asNodeArray, SafeJsxError } from "./interpreter-foundation";
|
||||
import type { AstNode, Environment, InterpreterCallback } from "./interpreter-foundation";
|
||||
import type { JsxEmitterContext } from "./emitter-context";
|
||||
import { diagnoseCustomJsxProps, normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
const supportedProps = new Set([
|
||||
"data",
|
||||
"childrenPath",
|
||||
"keyPath",
|
||||
"maxDepth",
|
||||
"maxNodes",
|
||||
"defaultExpandedDepth",
|
||||
"indent",
|
||||
"gap",
|
||||
"showLines",
|
||||
"bind",
|
||||
]);
|
||||
|
||||
export function emitRecursiveList(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
depth: number,
|
||||
component: ComponentType<never>,
|
||||
context: JsxEmitterContext,
|
||||
): ReactNode {
|
||||
const opening = asNode(node.openingElement, "RecursiveList opening element");
|
||||
const props: Record<string, unknown> = Object.create(null);
|
||||
for (const attribute of asNodeArray(opening.attributes, "RecursiveList attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const spread = context.evaluate(
|
||||
asNode(attribute.argument, "RecursiveList spread attribute"),
|
||||
environment,
|
||||
depth + 1,
|
||||
);
|
||||
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
|
||||
for (const [name, value] of Object.entries(spread)) {
|
||||
props[name] = value;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
|
||||
const nameNode = asNode(attribute.name, "RecursiveList attribute name");
|
||||
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
|
||||
const rawName = String(nameNode.name);
|
||||
const name = rawName === "bind" ? rawName : normalizedProperty(rawName);
|
||||
if (attribute.value == null) props[name] = true;
|
||||
else {
|
||||
const value = asNode(attribute.value, `RecursiveList ${name} value`);
|
||||
props[name] = value.type === "Literal" ? value.value : context.evaluate(value, environment, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const callback = recursiveListCallback(node, environment, context);
|
||||
const data = props.data;
|
||||
const childrenPath = typeof props.childrenPath === "string" ? props.childrenPath : "";
|
||||
const keyPath = typeof props.keyPath === "string" ? props.keyPath : "";
|
||||
const nodes = buildTrustedRecursiveList({
|
||||
data,
|
||||
childrenPath,
|
||||
keyPath,
|
||||
maxDepth: props.maxDepth,
|
||||
maxNodes: props.maxNodes,
|
||||
budget: context.budget,
|
||||
warnings: context.warnings,
|
||||
render: (value, metadata, callbackDepth) =>
|
||||
context.renderCallback(callback, [value, metadata], depth + callbackDepth + 1),
|
||||
});
|
||||
if (Object.hasOwn(props, "bind")) {
|
||||
context.warnings.add("BINDING_UNAVAILABLE: 'RecursiveList' does not have a declarative binding adapter");
|
||||
}
|
||||
diagnoseCustomJsxProps(props, "RecursiveList").forEach((diagnostic) => context.warnings.add(diagnostic));
|
||||
const rootProps = sanitizeCustomJsxProps(
|
||||
Object.fromEntries(Object.entries(props).filter(([name]) => !supportedProps.has(name))),
|
||||
"RecursiveList",
|
||||
);
|
||||
const trustedProps = {
|
||||
nodes,
|
||||
defaultExpandedDepth: finiteNumber(props.defaultExpandedDepth),
|
||||
indent: spacing(props.indent),
|
||||
gap: spacing(props.gap),
|
||||
showLines: props.showLines === true,
|
||||
rootProps,
|
||||
};
|
||||
context.budget.rendered();
|
||||
return createElement(component, trustedProps as never);
|
||||
}
|
||||
|
||||
function recursiveListCallback(
|
||||
node: AstNode,
|
||||
environment: Environment,
|
||||
context: JsxEmitterContext,
|
||||
): InterpreterCallback {
|
||||
const meaningfulChildren = asNodeArray(node.children, "RecursiveList children").filter(
|
||||
(child) => child.type !== "JSXText" || String(child.value ?? "").trim().length > 0,
|
||||
);
|
||||
if (meaningfulChildren.length !== 1 || meaningfulChildren[0]?.type !== "JSXExpressionContainer") {
|
||||
throw new SafeJsxError("RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList requires exactly one inline arrow child");
|
||||
}
|
||||
const expression = asNode(meaningfulChildren[0].expression, "RecursiveList template child");
|
||||
if (expression.type !== "ArrowFunctionExpression") {
|
||||
throw new SafeJsxError("RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList child must be an inline arrow");
|
||||
}
|
||||
const params = asNodeArray(expression.params, "RecursiveList template parameters");
|
||||
if (params.length === 0 || params.length > 2) {
|
||||
throw new SafeJsxError(
|
||||
"RECURSIVE_LIST_TEMPLATE_REQUIRED: RecursiveList child accepts node and optional metadata parameters",
|
||||
);
|
||||
}
|
||||
return context.createCallback(expression, environment);
|
||||
}
|
||||
|
||||
function spacing(value: unknown): string | number | undefined {
|
||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { SafeJsxError } from "./interpreter-foundation";
|
||||
import { CUSTOM_JSX_BLOCKED_PROPERTIES, normalizeCustomJsxProperty } from "./policy";
|
||||
import { ownProperty } from "./safe-properties";
|
||||
|
||||
export const DEFAULT_MAX_DEPTH = 16;
|
||||
export const HARD_MAX_DEPTH = 32;
|
||||
export const DEFAULT_MAX_NODES = 500;
|
||||
export const HARD_MAX_NODES = 2_000;
|
||||
|
||||
export function exceedsRecursiveListNodeLimit(
|
||||
roots: readonly unknown[],
|
||||
childrenPath: readonly string[],
|
||||
maxDepth: number,
|
||||
maxNodes: number,
|
||||
) {
|
||||
let rows = 0;
|
||||
const charge = () => {
|
||||
rows += 1;
|
||||
return rows > maxNodes;
|
||||
};
|
||||
const visit = (values: readonly unknown[], depth: number, ancestors: ReadonlySet<object>): boolean => {
|
||||
for (const value of values) {
|
||||
if (charge()) return true;
|
||||
if (!isObject(value) || ancestors.has(value)) continue;
|
||||
|
||||
const rawChildren = readRestrictedPath(value, childrenPath);
|
||||
if (rawChildren !== undefined && rawChildren !== null && !Array.isArray(rawChildren)) {
|
||||
if (charge()) return true;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(rawChildren) || rawChildren.length === 0) continue;
|
||||
if (depth + 1 >= maxDepth) {
|
||||
if (charge()) return true;
|
||||
continue;
|
||||
}
|
||||
if (visit(rawChildren, depth + 1, new Set(ancestors).add(value))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return visit(roots, 0, new Set());
|
||||
}
|
||||
|
||||
export function parseRestrictedPath(value: string, name: string): string[] {
|
||||
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/u.test(value)) {
|
||||
throw new SafeJsxError(`RECURSIVE_LIST_INVALID_PATH: ${name} must be a dotted property path`);
|
||||
}
|
||||
const segments = value.split(".");
|
||||
for (const segment of segments) {
|
||||
if (CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(segment))) {
|
||||
throw new SafeJsxError(`RECURSIVE_LIST_INVALID_PATH: ${name} contains blocked segment '${segment}'`);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function readRestrictedPath(value: unknown, path: readonly string[]): unknown {
|
||||
let current = value;
|
||||
for (const segment of path) current = ownProperty(current, segment);
|
||||
return current;
|
||||
}
|
||||
|
||||
export function primitiveKey(value: unknown): string | null {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : null;
|
||||
}
|
||||
|
||||
export function clampWithWarning(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
name: string,
|
||||
warnings: Set<string>,
|
||||
) {
|
||||
const validNumber = typeof value === "number" && Number.isFinite(value);
|
||||
const number = validNumber ? Math.trunc(value) : fallback;
|
||||
const clamped = Math.min(maximum, Math.max(minimum, number));
|
||||
if (value !== undefined && (!validNumber || number !== clamped)) {
|
||||
warnings.add(`RECURSIVE_LIST_LIMIT_CLAMPED: ${name} was clamped to ${clamped}`);
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is object {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function formatPath(path: readonly number[]) {
|
||||
return path.length === 0 ? "root" : path.join(".");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { MantineSpacing, TreeNodeData } from "@mantine/core";
|
||||
|
||||
import type { Budget } from "./interpreter-foundation";
|
||||
|
||||
export interface RecursiveListMetadata {
|
||||
depth: number;
|
||||
index: number;
|
||||
path: number[];
|
||||
key: string;
|
||||
hasChildren: boolean;
|
||||
childCount: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export interface RecursiveListBuildOptions {
|
||||
data: unknown;
|
||||
childrenPath: string;
|
||||
keyPath: string;
|
||||
maxDepth?: unknown;
|
||||
maxNodes?: unknown;
|
||||
budget: Budget;
|
||||
warnings: Set<string>;
|
||||
render(node: unknown, metadata: RecursiveListMetadata, depth: number): ReactNode;
|
||||
}
|
||||
|
||||
export interface TrustedRecursiveListProps {
|
||||
nodes: TreeNodeData[];
|
||||
defaultExpandedDepth?: number;
|
||||
indent?: MantineSpacing;
|
||||
gap?: MantineSpacing;
|
||||
showLines?: boolean;
|
||||
rootProps?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useMemo } from "react";
|
||||
import { Box, Group, Text, Tree, useTree } from "@mantine/core";
|
||||
import type { MantineSpacing, RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
|
||||
import { buildTrustedRecursiveList } from "./recursive-list-builder";
|
||||
import type { TrustedRecursiveListProps } from "./recursive-list-types";
|
||||
|
||||
const HARD_MAX_DEPTH = 32;
|
||||
|
||||
export { buildTrustedRecursiveList };
|
||||
export type {
|
||||
RecursiveListBuildOptions,
|
||||
RecursiveListMetadata,
|
||||
TrustedRecursiveListProps,
|
||||
} from "./recursive-list-types";
|
||||
|
||||
export function TrustedRecursiveList({ ...props }: TrustedRecursiveListProps) {
|
||||
return (
|
||||
<RecursiveListErrorBoundary resetKey={props.nodes}>
|
||||
<TrustedRecursiveListInner {...props} />
|
||||
</RecursiveListErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustedRecursiveListInner({
|
||||
nodes,
|
||||
defaultExpandedDepth = 4,
|
||||
indent = "lg",
|
||||
gap = "xs",
|
||||
showLines = false,
|
||||
rootProps = {},
|
||||
}: TrustedRecursiveListProps) {
|
||||
const initialExpandedState = useMemo(
|
||||
() => collectInitialExpandedState(nodes, clampInteger(defaultExpandedDepth, 0, HARD_MAX_DEPTH, 4)),
|
||||
[defaultExpandedDepth, nodes],
|
||||
);
|
||||
const tree = useTree({ initialExpandedState });
|
||||
|
||||
return (
|
||||
<Tree
|
||||
{...rootProps}
|
||||
data={nodes}
|
||||
tree={tree}
|
||||
levelOffset={indent}
|
||||
withLines={showLines}
|
||||
renderNode={(payload) => <TrustedRecursiveListNode {...payload} gap={gap} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
class RecursiveListErrorBoundary extends Component<
|
||||
{ children: ReactNode; resetKey: readonly TreeNodeData[] },
|
||||
{ error: Error | null; resetKey: readonly TreeNodeData[] }
|
||||
> {
|
||||
public state = { error: null, resetKey: this.props.resetKey } as {
|
||||
error: Error | null;
|
||||
resetKey: readonly TreeNodeData[];
|
||||
};
|
||||
public static getDerivedStateFromProps(
|
||||
props: Readonly<{ children: ReactNode; resetKey: readonly TreeNodeData[] }>,
|
||||
state: Readonly<{ error: Error | null; resetKey: readonly TreeNodeData[] }>,
|
||||
) {
|
||||
if (props.resetKey === state.resetKey) return null;
|
||||
return { error: null, resetKey: props.resetKey };
|
||||
}
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
public componentDidCatch(_error: Error, _info: ErrorInfo) {}
|
||||
public render() {
|
||||
return this.state.error ? (
|
||||
<Text size="xs" c="red">
|
||||
RECURSIVE_LIST_RENDER_ERROR: {this.state.error.message}
|
||||
</Text>
|
||||
) : (
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function TrustedRecursiveListNode({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
gap,
|
||||
}: RenderTreeNodePayload & { gap: MantineSpacing }) {
|
||||
return (
|
||||
<Group {...elementProps} gap={gap} wrap="nowrap" py={2}>
|
||||
<Box w={16} h={16} style={{ flex: "0 0 16px" }} aria-hidden>
|
||||
{hasChildren && (
|
||||
<IconChevronRight
|
||||
size={15}
|
||||
style={{ transform: expanded ? "rotate(90deg)" : undefined, transition: "transform 120ms ease" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>{node.label}</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function collectInitialExpandedState(nodes: readonly TreeNodeData[], depthLimit: number) {
|
||||
const expanded: Record<string, boolean> = {};
|
||||
const visit = (values: readonly TreeNodeData[], depth: number) => {
|
||||
for (const node of values) {
|
||||
if (!node.children?.length || depth >= depthLimit) continue;
|
||||
expanded[node.value] = true;
|
||||
visit(node.children, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(nodes, 0);
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function clampInteger(value: number, minimum: number, maximum: number, fallback: number) {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function isSafeCustomJsxUrl(value: unknown) {
|
||||
if (typeof value !== "string") return false;
|
||||
if (value.startsWith("#")) return true;
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getScopedCustomJsxControlName(scopeId: string, name: string) {
|
||||
const safeScope = scopeId.replaceAll(/[^A-Za-z0-9_-]/gu, "_");
|
||||
const safeName = encodeURIComponent(name).replaceAll("%", "_");
|
||||
return `custom-widget-${safeScope}-${safeName}`;
|
||||
}
|
||||
|
||||
export function getInvalidCustomJsxPropValueReason(componentName: string, propName: string, value: unknown) {
|
||||
if (
|
||||
["DateTimePicker", "InlineDateTimePicker"].includes(componentName) &&
|
||||
propName === "type" &&
|
||||
![undefined, "default", "range"].includes(value as undefined | string)
|
||||
) {
|
||||
return `${componentName}.type supports only 'default' or 'range'`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { createContext, createElement, useContext, useEffect, useRef, useState } from "react";
|
||||
import { createContext, createElement, useContext, useEffect, useId, useRef, useState } from "react";
|
||||
import * as Core from "@mantine/core";
|
||||
import * as Charts from "@mantine/charts";
|
||||
import * as Dates from "@mantine/dates";
|
||||
@@ -15,6 +15,8 @@ import { SubData } from "../runtime/data";
|
||||
import { RefreshButton } from "../runtime/refresh-button";
|
||||
import { SubFetch } from "../runtime/sub-fetch";
|
||||
import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge } from "./interactive-components";
|
||||
import { TrustedRecursiveList } from "./recursive-list";
|
||||
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
|
||||
import { sanitizeCustomJsxProps } from "./safe-properties";
|
||||
|
||||
type Namespace = Readonly<Record<string, unknown>>;
|
||||
@@ -32,13 +34,10 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeUrl(value: unknown) {
|
||||
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
|
||||
}
|
||||
|
||||
export type WidgetInputValue = string | number | boolean | string[] | number[];
|
||||
export type WidgetInputType = CustomJsxBindingType;
|
||||
interface CustomJsxInputsContextValue {
|
||||
scopeId: string;
|
||||
inputs: Record<string, WidgetInputValue>;
|
||||
inputTypes: Record<string, WidgetInputType>;
|
||||
registerInput(name: string, type: WidgetInputType, initialValue: WidgetInputValue): void;
|
||||
@@ -49,20 +48,21 @@ const CustomJsxInputsContext = createContext<CustomJsxInputsContextValue | null>
|
||||
|
||||
export function CustomJsxInputsProvider({
|
||||
children,
|
||||
scopeId,
|
||||
inputs,
|
||||
inputTypes,
|
||||
registerInput,
|
||||
setInputValue,
|
||||
}: CustomJsxInputsContextValue & { children: ReactNode }) {
|
||||
return (
|
||||
<CustomJsxInputsContext.Provider value={{ inputs, inputTypes, registerInput, setInputValue }}>
|
||||
<CustomJsxInputsContext.Provider value={{ scopeId, inputs, inputTypes, registerInput, setInputValue }}>
|
||||
{children}
|
||||
</CustomJsxInputsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const checkedComponents = new Set(["Checkbox", "Switch"]);
|
||||
const openedComponents = new Set(["HoverCard", "Menu", "Popover"]);
|
||||
const openedComponents = new Set(["Menu", "Popover"]);
|
||||
const activeComponents = new Set(["Stepper"]);
|
||||
const comboboxComponents = new Set(["Autocomplete", "MultiSelect", "Select", "TagsInput", "TreeSelect"]);
|
||||
const popoverInputComponents = new Set([
|
||||
@@ -71,8 +71,11 @@ const popoverInputComponents = new Set([
|
||||
"DatePickerInput",
|
||||
"DateTimePicker",
|
||||
"MonthPickerInput",
|
||||
"TimePicker",
|
||||
"YearPickerInput",
|
||||
]);
|
||||
const modalPickerComponents = new Set(["DatePickerInput", "DateTimePicker", "MonthPickerInput", "YearPickerInput"]);
|
||||
const namedRadioComponents = new Set(["Radio", "Radio.Card", "Radio.Group", "RadioCard", "RadioGroup"]);
|
||||
const buttonRootComponents = new Set(["ActionIcon", "Burger", "Button", "CloseButton", "UnstyledButton"]);
|
||||
|
||||
function extractEventValue(value: unknown, checked: boolean): WidgetInputValue | null {
|
||||
@@ -92,28 +95,29 @@ function extractEventValue(value: unknown, checked: boolean): WidgetInputValue |
|
||||
}
|
||||
|
||||
function useBoundProps(componentName: string, props: Record<string, unknown>): Record<string, unknown> {
|
||||
const detachedScopeId = useId();
|
||||
const binding = typeof props.bind === "string" ? props.bind : undefined;
|
||||
const context = useContext(CustomJsxInputsContext);
|
||||
const sanitized = sanitizeCustomJsxProps(props);
|
||||
const sanitized = sanitizeCustomJsxProps(props, componentName);
|
||||
if (namedRadioComponents.has(componentName) && typeof sanitized.name === "string") {
|
||||
sanitized.name = getScopedCustomJsxControlName(context?.scopeId ?? detachedScopeId, sanitized.name);
|
||||
}
|
||||
delete sanitized.bind;
|
||||
const inputType = getCustomJsxBindingType(componentName, sanitized);
|
||||
const initialValue = getInitialInputValue(inputType, sanitized);
|
||||
const isBound = Boolean(binding && context && inputType && customJsxBindableComponentNames.has(componentName));
|
||||
useEffect(() => {
|
||||
if (isBound && binding && context && inputType) context.registerInput(binding, inputType, initialValue);
|
||||
}, [binding, context, initialValue, inputType, isBound]);
|
||||
if (!isBound || !binding || !context || !inputType) return sanitized;
|
||||
delete sanitized.defaultValue;
|
||||
delete sanitized.defaultChecked;
|
||||
useEffect(() => {
|
||||
if (binding && context && inputType && customJsxBindableComponentNames.has(componentName))
|
||||
context.registerInput(binding, inputType, initialValue);
|
||||
}, [binding, componentName, context, initialValue, inputType]);
|
||||
if (!binding || !context || !inputType || !customJsxBindableComponentNames.has(componentName)) return sanitized;
|
||||
const currentValue = context.inputs[binding] ?? initialValue;
|
||||
|
||||
const update = (value: unknown, checked = false) => {
|
||||
const extracted = extractEventValue(value, checked);
|
||||
context.setInputValue(binding, inputType, extracted ?? emptyInputValue(inputType));
|
||||
};
|
||||
if (componentName === "Calendar") {
|
||||
return { ...sanitized, date: currentValue, onDateChange: update };
|
||||
}
|
||||
if (checkedComponents.has(componentName)) {
|
||||
return {
|
||||
...sanitized,
|
||||
@@ -175,7 +179,7 @@ function SafeLink({ component, props }: { component: ComponentType<never>; props
|
||||
component,
|
||||
{
|
||||
...sanitized,
|
||||
href: safeUrl(props.href) ? props.href : undefined,
|
||||
href: isSafeCustomJsxUrl(props.href) ? props.href : undefined,
|
||||
target,
|
||||
rel: target === "_blank" ? "noopener noreferrer" : sanitized.rel,
|
||||
} as never,
|
||||
@@ -234,6 +238,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
) {
|
||||
additions.withinPortal = false;
|
||||
}
|
||||
if (modalPickerComponents.has(descriptor.name)) additions.dropdownType = "popover";
|
||||
if (buttonRootComponents.has(descriptor.name)) additions.type = "button";
|
||||
components[descriptor.name] = wrap(descriptor.name, component, additions);
|
||||
}
|
||||
@@ -259,13 +264,6 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
const navLink = resolveExport(core, "NavLink");
|
||||
if (anchor) components.Anchor = (props) => <SafeLink component={anchor} props={props} />;
|
||||
if (navLink) components.NavLink = (props) => <SafeLink component={navLink} props={props} />;
|
||||
const dates = Dates as unknown as Namespace;
|
||||
for (const descriptor of enabledCustomJsxComponents.filter(
|
||||
(item) => item.package === "@mantine/dates" && item.safety === "wrapped",
|
||||
)) {
|
||||
const component = resolveExport(dates, descriptor.name);
|
||||
if (component) components[descriptor.name] = wrap(descriptor.name, component);
|
||||
}
|
||||
Object.assign(components, {
|
||||
CopyButton: createCopyButton(adapters.copyLabels),
|
||||
PaginatedList,
|
||||
@@ -280,6 +278,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
|
||||
ActionButton,
|
||||
ToggleSwitch,
|
||||
RefreshButton,
|
||||
RecursiveList: TrustedRecursiveList,
|
||||
});
|
||||
return components;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export const CUSTOM_JSX_BINARY_OPERATORS: ReadonlySet<string> = new Set([
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
"**",
|
||||
"<",
|
||||
"<=",
|
||||
">",
|
||||
">=",
|
||||
"==",
|
||||
"!=",
|
||||
"===",
|
||||
"!==",
|
||||
]);
|
||||
|
||||
export const CUSTOM_JSX_CALLBACK_METHODS: ReadonlySet<string> = new Set([
|
||||
"every",
|
||||
"filter",
|
||||
"find",
|
||||
"findIndex",
|
||||
"map",
|
||||
"reduce",
|
||||
"some",
|
||||
"sort",
|
||||
]);
|
||||
|
||||
export const CUSTOM_JSX_SAFE_VALUE_METHODS: ReadonlySet<string> = new Set([
|
||||
...CUSTOM_JSX_CALLBACK_METHODS,
|
||||
"at",
|
||||
"charAt",
|
||||
"endsWith",
|
||||
"flat",
|
||||
"includes",
|
||||
"indexOf",
|
||||
"join",
|
||||
"lastIndexOf",
|
||||
"padEnd",
|
||||
"padStart",
|
||||
"pop",
|
||||
"repeat",
|
||||
"replace",
|
||||
"replaceAll",
|
||||
"reverse",
|
||||
"slice",
|
||||
"split",
|
||||
"startsWith",
|
||||
"substring",
|
||||
"toFixed",
|
||||
"toLowerCase",
|
||||
"toPrecision",
|
||||
"toString",
|
||||
"toUpperCase",
|
||||
"trim",
|
||||
"trimEnd",
|
||||
"trimStart",
|
||||
]);
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isValidElement } from "react";
|
||||
|
||||
import { isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
|
||||
import { customJsxComponentByName } from "../core/component-registry";
|
||||
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
|
||||
import {
|
||||
CUSTOM_JSX_BLOCKED_PROPERTIES,
|
||||
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
|
||||
@@ -83,15 +87,23 @@ function sanitizeStyles(value: unknown): Record<string, Record<string, string |
|
||||
|
||||
export function sanitizeCustomJsxProps(
|
||||
props: Readonly<Record<string, unknown>>,
|
||||
_componentName?: string,
|
||||
componentName?: string,
|
||||
): Record<string, unknown> {
|
||||
return sanitizeObject(props);
|
||||
const blockedProps = componentName
|
||||
? new Set(customJsxComponentByName.get(componentName)?.blockedProps.map(({ name }) => name) ?? [])
|
||||
: undefined;
|
||||
return sanitizeObject(props, blockedProps, componentName);
|
||||
}
|
||||
|
||||
function sanitizeObject(value: Readonly<Record<string, unknown>>): Record<string, unknown> {
|
||||
function sanitizeObject(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
componentBlockedProps?: ReadonlySet<string>,
|
||||
componentName?: string,
|
||||
): Record<string, unknown> {
|
||||
const safe: Record<string, unknown> = Object.create(null);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (isBlockedCustomJsxProp(key)) continue;
|
||||
if (isBlockedCustomJsxProp(key) || componentBlockedProps?.has(key)) continue;
|
||||
if (componentName && getInvalidCustomJsxPropValueReason(componentName, key, child)) continue;
|
||||
if (typeof child === "function" || isInterpreterCallback(child)) continue;
|
||||
if (CUSTOM_JSX_URL_PROPS.has(key)) {
|
||||
if (isSafeUrl(child)) safe[key] = child;
|
||||
@@ -127,6 +139,8 @@ function sanitizeSerializableData(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function sanitizeNestedValue(value: unknown): unknown {
|
||||
if (typeof value === "function" || isInterpreterCallback(value)) return undefined;
|
||||
if (isValidElement(value)) return value;
|
||||
if (Array.isArray(value)) return value.map(sanitizeNestedValue);
|
||||
if (value !== null && typeof value === "object") return sanitizeObject(value as Record<string, unknown>);
|
||||
return value;
|
||||
@@ -134,13 +148,34 @@ function sanitizeNestedValue(value: unknown): unknown {
|
||||
|
||||
export function diagnoseCustomJsxProps(props: Readonly<Record<string, unknown>>, componentName: string): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
diagnoseObject(props, componentName, diagnostics);
|
||||
const blockedProps = new Map(
|
||||
customJsxComponentByName.get(componentName)?.blockedProps.map(({ name, reason }) => [name, reason]) ?? [],
|
||||
);
|
||||
diagnoseObject(props, componentName, diagnostics, blockedProps, componentName);
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function diagnoseObject(value: Readonly<Record<string, unknown>>, path: string, diagnostics: string[]) {
|
||||
function diagnoseObject(
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
path: string,
|
||||
diagnostics: string[],
|
||||
componentBlockedProps?: ReadonlyMap<string, string>,
|
||||
componentName?: string,
|
||||
) {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const propertyPath = `${path}.${key}`;
|
||||
const componentBlockedReason = componentBlockedProps?.get(key);
|
||||
if (componentBlockedReason) {
|
||||
diagnostics.push(
|
||||
`BLOCKED_CAPABILITY: Prop '${propertyPath}' is not allowed because ${componentBlockedReason.toLowerCase()}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const invalidValueReason = componentName ? getInvalidCustomJsxPropValueReason(componentName, key, child) : null;
|
||||
if (invalidValueReason) {
|
||||
diagnostics.push(`INVALID_PROP_VALUE: '${propertyPath}' is not allowed because ${invalidValueReason}`);
|
||||
continue;
|
||||
}
|
||||
if (isBlockedCustomJsxProp(key) || typeof child === "function" || isInterpreterCallback(child)) {
|
||||
diagnostics.push(`BLOCKED_CAPABILITY: Prop '${propertyPath}' is not allowed`);
|
||||
continue;
|
||||
@@ -149,9 +184,11 @@ function diagnoseObject(value: Readonly<Record<string, unknown>>, path: string,
|
||||
diagnostics.push(`INVALID_PROP_VALUE: '${propertyPath}' contains an unsafe URL`);
|
||||
continue;
|
||||
}
|
||||
if (isValidElement(child)) continue;
|
||||
if (key === "data" || key === "series") continue;
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach((entry, index) => {
|
||||
if (isValidElement(entry)) return;
|
||||
if (isInterpreterCallback(entry) || typeof entry === "function") {
|
||||
diagnostics.push(`BLOCKED_CAPABILITY: Prop '${propertyPath}[${index}]' is not allowed`);
|
||||
} else if (entry !== null && typeof entry === "object") {
|
||||
|
||||
Reference in new issue
Block a user