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

322 lines
13 KiB
TypeScript

import { Parser } from "acorn";
import jsx from "acorn-jsx";
import {
CUSTOM_JSX_BLOCKED_PROPERTIES,
CUSTOM_JSX_LIMITS,
isBlockedCustomJsxLexicalBinding,
normalizeCustomJsxProperty,
} from "./policy";
import type { AstNode } from "./analyzer-ast";
import { nodeOf, nodesOf, staticPropertyName } from "./analyzer-ast";
import { createCustomJsxParseDiagnostic } from "./analyzer-diagnostics";
import type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
import { analyzeCustomJsxElement } from "./analyzer-jsx";
import {
canStaticallyProduceCallable,
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";
import { isSafeRegexLiteral } from "./regex-policy";
export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
const JsxParser = Parser.extend(jsx());
const requestStatusLabels = new Set(["loading", "success", "error"]);
function directRequestStatusId(node: AstNode | null): string | null {
if (node?.type !== "MemberExpression") return null;
const object = nodeOf(node.object);
if (object?.type !== "Identifier" || object.name !== "status") return null;
const property = nodeOf(node.property);
return node.computed
? (staticPropertyName(property) ?? null)
: String(property?.name ?? property?.value ?? "") || null;
}
function invalidRequestStatusComparison(node: AstNode) {
if (!["===", "==", "!==", "!="].includes(String(node.operator))) return null;
const left = nodeOf(node.left);
const right = nodeOf(node.right);
const leftStatus = directRequestStatusId(left);
const rightStatus = directRequestStatusId(right);
const leftLabel = left?.type === "Literal" && typeof left.value === "string" ? left.value : null;
const rightLabel = right?.type === "Literal" && typeof right.value === "string" ? right.value : null;
const requestId =
leftStatus && rightLabel && requestStatusLabels.has(rightLabel)
? leftStatus
: rightStatus && leftLabel && requestStatusLabels.has(leftLabel)
? rightStatus
: null;
return requestId
? `INVALID_STATUS_COMPARISON: status.${requestId} is an object. Use status.${requestId}?.loading, status.${requestId}?.ok === true, or status.${requestId}?.ok === false`
: null;
}
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
let operations = 0;
const add = (node: AstNode, message: string, severity: "error" | "warning" = "error") => {
const line = node.loc?.start?.line ?? 1;
diagnostics.push({
severity,
message,
index: Math.max(0, (node.start ?? 2) - 2),
line,
column: Math.max(1, (node.loc?.start?.column ?? 0) + 1 - (line === 1 ? 2 : 0)),
});
};
const checkBudget = (node: AstNode, depth: number) => {
operations += 1;
if (depth > CUSTOM_JSX_LIMITS.astDepth)
add(node, `Template exceeds the AST depth limit (${CUSTOM_JSX_LIMITS.astDepth})`);
if (operations === CUSTOM_JSX_LIMITS.operations + 1) {
add(node, `Template exceeds the operation limit (${CUSTOM_JSX_LIMITS.operations})`);
}
return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations;
};
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")
add(body, "UNSUPPORTED_BLOCK_STATEMENT: Use one expression in authored callbacks");
else if (canStaticallyProduceCallable(body)) {
add(body, "CALLBACK_VALUE_NOT_ALLOWED: Authored callbacks cannot return callable values");
} else visit(body, depth + 1, callbackBindings);
};
visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
if (!checkBudget(node, depth)) return;
switch (node.type) {
case "Program":
nodesOf(node.body).forEach((child) => visit(child, depth + 1, bindings));
return;
case "ExpressionStatement": {
const expression = nodeOf(node.expression);
if (expression) visit(expression, depth + 1, bindings);
return;
}
case "JSXFragment":
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
return;
case "JSXElement": {
analyzeCustomJsxElement(node, depth, bindings, { add, visit, visitArrow });
return;
}
case "JSXText":
case "JSXEmptyExpression":
case "TemplateElement":
return;
case "JSXExpressionContainer": {
const expression = nodeOf(node.expression);
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
return;
}
case "Literal":
if (typeof node.value === "bigint") add(node, "BIGINT_NOT_SUPPORTED: Use Number for widget values");
if (node.regex !== undefined && !isSafeRegexLiteral(node.regex))
add(node, "UNSAFE_REGEX: Use a conservative regex without repeated alternatives or variable quantifiers");
return;
case "Identifier": {
const name = String(node.name ?? "");
if (!bindings.has(name)) add(node, `Unknown binding '${name}'`);
return;
}
case "ArrayExpression":
nodesOf(node.elements).forEach((child) => {
const argument = child.type === "SpreadElement" ? nodeOf(child.argument) : child;
if (argument) visit(argument, depth + 1, bindings);
});
return;
case "ObjectExpression":
nodesOf(node.properties).forEach((property) => {
if (property.type === "SpreadElement") {
const argument = nodeOf(property.argument);
if (argument) visit(argument, depth + 1, bindings);
return;
}
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
add(property, "Only explicit object properties are supported");
return;
}
const key = nodeOf(property.key);
if (property.computed && key) visit(key, depth + 1, bindings);
if (
!property.computed &&
key &&
CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(key.name ?? key.value))
) {
add(key, "Reflective object properties are not allowed");
}
const value = nodeOf(property.value);
if (value) visit(value, depth + 1, bindings);
});
return;
case "UnaryExpression":
if (!["!", "+", "-", "typeof"].includes(String(node.operator))) {
add(node, `Unary operator '${String(node.operator)}' is not supported`);
}
if (nodeOf(node.argument)) visit(node.argument as AstNode, depth + 1, bindings);
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`);
}
if (node.type === "BinaryExpression") {
const statusDiagnostic = invalidRequestStatusComparison(node);
if (statusDiagnostic) add(node, statusDiagnostic);
}
const left = nodeOf(node.left);
const right = nodeOf(node.right);
if (left) visit(left, depth + 1, bindings);
if (right) visit(right, depth + 1, bindings);
return;
}
case "ConditionalExpression": {
[node.test, node.consequent, node.alternate].map(nodeOf).forEach((child) => {
if (child) visit(child, depth + 1, bindings);
});
return;
}
case "MemberExpression": {
const object = nodeOf(node.object);
const property = nodeOf(node.property);
if (object) visit(object, depth + 1, bindings);
if (node.computed && property) visit(property, depth + 1, bindings);
const propertyName = node.computed
? staticPropertyName(property)
: String(property?.name ?? property?.value ?? "");
if (propertyName && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(propertyName))) {
add(property ?? node, "Reflective property access is not allowed");
}
return;
}
case "CallExpression": {
const callee = nodeOf(node.callee);
const callArguments = nodesOf(node.arguments);
if (node.optional) {
add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported");
}
if (callee?.type === "ArrowFunctionExpression") {
add(callee, "CALL_TARGET_NOT_ALLOWED: Inline IIFEs are not supported");
return;
}
if (callee) visit(callee, depth + 1, bindings);
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 = callArguments[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`);
}
}
callArguments.forEach((argument, argumentIndex) => {
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
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": {
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: Authored callbacks must use one expression");
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;
case "ChainExpression": {
const expression = nodeOf(node.expression);
if (expression) visit(expression, depth + 1, bindings);
return;
}
default:
add(node, `Unsupported expression '${node.type}'`);
}
};
try {
const program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
locations: true,
}) as unknown as AstNode;
visit(program, 0, ROOT_BINDINGS);
} catch (error) {
diagnostics.push(createCustomJsxParseDiagnostic(template, error));
}
return diagnostics;
}