fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
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);
}
+197 -157
View File
@@ -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;
}
+1
View File
@@ -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;
}
+50 -77
View File
@@ -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") {
+15 -1
View File
@@ -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") {