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

203 lines
7.9 KiB
TypeScript

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,
CUSTOM_JSX_URL_PROPS,
isBlockedCustomJsxProp,
normalizeCustomJsxProperty,
} from "./policy";
export function normalizedProperty(value: unknown): string {
if (typeof value !== "string" && typeof value !== "number") {
throw new SafeJsxError("Property names must be strings or numbers");
}
const property = String(value);
if (CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(property))) {
throw new SafeJsxError(`Access to reflective property '${property}' is not allowed`);
}
return property;
}
function isArrayIndex(property: string): boolean {
if (!/^(?:0|[1-9]\d*)$/.test(property)) return false;
const index = Number(property);
return Number.isSafeInteger(index) && index >= 0;
}
export function ownProperty(object: unknown, propertyValue: unknown): unknown {
const property = normalizedProperty(propertyValue);
if (object == null) return undefined;
if (Array.isArray(object)) {
if (property === "length") return object.length;
if (!isArrayIndex(property)) return undefined;
return Object.hasOwn(object, property) ? object[Number(property)] : undefined;
}
if (typeof object === "string") {
if (property === "length") return object.length;
if (!isArrayIndex(property)) return undefined;
return object[Number(property)];
}
if (typeof object !== "object") return undefined;
return Object.hasOwn(object, property) ? (object as Record<string, unknown>)[property] : undefined;
}
function isSafeUrl(value: unknown): boolean {
if (typeof value !== "string") return false;
if (value.startsWith("#")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const parsed = new URL(value);
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password;
} catch {
return false;
}
}
function sanitizeStyle(value: unknown): Record<string, string | number> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, string | number> = {};
for (const [key, styleValue] of Object.entries(value)) {
if (CUSTOM_JSX_BLOCKED_STYLE_KEYS.has(key)) continue;
if (typeof styleValue !== "string" && typeof styleValue !== "number") continue;
if (typeof styleValue === "string" && /(?:url\s*\(|expression\s*\(|javascript:|position\s*:)/i.test(styleValue)) {
continue;
}
result[key] = styleValue;
}
return result;
}
function sanitizeStyles(value: unknown): Record<string, Record<string, string | number>> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, Record<string, string | number>> = {};
for (const [selector, styles] of Object.entries(value)) {
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(selector)) continue;
const sanitized = sanitizeStyle(styles);
if (sanitized) result[selector] = sanitized;
}
return result;
}
export function sanitizeCustomJsxProps(
props: Readonly<Record<string, unknown>>,
componentName?: string,
): Record<string, unknown> {
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>>,
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) || 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;
continue;
}
if (key === "style") {
const style = sanitizeStyle(child);
if (style) safe.style = style;
continue;
}
if (key === "styles") {
const styles = sanitizeStyles(child);
if (styles) safe.styles = styles;
continue;
}
safe[key] = key === "data" || key === "series" ? sanitizeSerializableData(child) : sanitizeNestedValue(child);
}
return safe;
}
function sanitizeSerializableData(value: unknown): unknown {
if (typeof value === "function" || isInterpreterCallback(value)) return undefined;
if (Array.isArray(value)) return value.map(sanitizeSerializableData);
if (value !== null && typeof value === "object") {
const safe: Record<string, unknown> = Object.create(null);
for (const [key, child] of Object.entries(value)) {
if (CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(key))) continue;
safe[key] = sanitizeSerializableData(child);
}
return safe;
}
return value;
}
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;
}
export function diagnoseCustomJsxProps(props: Readonly<Record<string, unknown>>, componentName: string): string[] {
const diagnostics: string[] = [];
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[],
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;
}
if (CUSTOM_JSX_URL_PROPS.has(key) && !isSafeUrl(child)) {
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") {
diagnoseObject(entry as Record<string, unknown>, `${propertyPath}[${index}]`, diagnostics);
}
});
} else if (child !== null && typeof child === "object" && key !== "style" && key !== "styles") {
diagnoseObject(child as Record<string, unknown>, propertyPath, diagnostics);
}
}
}