555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
import { MantineProvider } from "@mantine/core";
|
|
import * as MantineCharts from "@mantine/charts";
|
|
import * as MantineCore from "@mantine/core";
|
|
import * as MantineDates from "@mantine/dates";
|
|
import { act, createElement } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { ReactNode } from "react";
|
|
import type { Root } from "react-dom/client";
|
|
|
|
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
|
|
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
|
|
|
|
import CustomJsxDisplay from "./custom-jsx-display";
|
|
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
|
|
|
|
const WHITELISTED_COMPONENTS = createWhitelistedComponents({ copy: "Copy", copied: "Copied" });
|
|
|
|
vi.mock("@homarr/translation/client", () => ({
|
|
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
|
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
|
|
}));
|
|
|
|
const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
|
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false;
|
|
if (typeof value === "function") return true;
|
|
if (!value || typeof value !== "object") return false;
|
|
const reactType = (value as { $$typeof?: unknown }).$$typeof;
|
|
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
|
|
};
|
|
|
|
describe("CustomJsxDisplay", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
|
Object.defineProperty(window, "matchMedia", {
|
|
writable: true,
|
|
value: (query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: () => undefined,
|
|
removeListener: () => undefined,
|
|
addEventListener: () => undefined,
|
|
removeEventListener: () => undefined,
|
|
dispatchEvent: () => false,
|
|
}),
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root?.unmount());
|
|
container?.remove();
|
|
});
|
|
|
|
const renderDisplay = async (data: Record<string, unknown>) => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data })));
|
|
});
|
|
};
|
|
|
|
it("renders Mantine components with data bindings", async () => {
|
|
await renderDisplay({
|
|
template: "<Stack><Title order={3}>{data.name}</Title><Badge>{data.count}</Badge></Stack>",
|
|
data: { name: "Server A", count: "3" },
|
|
});
|
|
|
|
expect(container.textContent).toContain("Server A");
|
|
expect(container.textContent).toContain("3");
|
|
});
|
|
|
|
it("blocks script tags and event handlers", async () => {
|
|
await renderDisplay({
|
|
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
|
|
data: {},
|
|
});
|
|
|
|
expect(document.title).not.toBe("hacked");
|
|
expect(container.querySelector("script")).toBeNull();
|
|
expect(container.textContent).toContain("Safe");
|
|
});
|
|
|
|
it("supports bounded collection callbacks and safe string helpers", async () => {
|
|
await renderDisplay({
|
|
template:
|
|
'<Stack>{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => <Text key={i}>{String(i + 1) + ". " + item.name.toUpperCase()}</Text>)}</Stack>',
|
|
data: {
|
|
items: [
|
|
{ name: "alpha", enabled: true },
|
|
{ name: "hidden", enabled: false },
|
|
{ name: "beta", enabled: true },
|
|
{ name: "gamma", enabled: true },
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(container.textContent).toContain("1. ALPHA");
|
|
expect(container.textContent).toContain("2. BETA");
|
|
expect(container.textContent).not.toContain("GAMMA");
|
|
});
|
|
|
|
it("renders fetched data through an interpreter-created SubFetch child", async () => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
const rendered = renderSafeJsx({
|
|
template:
|
|
"<SubFetch>{(detail, meta) => <Text>{detail.types.map((entry) => entry.name).join(', ')} · {meta.status}</Text>}</SubFetch>",
|
|
components: {
|
|
SubFetch: ((props: { render?: (data: unknown, metadata: unknown) => ReactNode }) =>
|
|
props.render?.({ types: [{ name: "grass" }, { name: "poison" }] }, { status: 200 })) as never,
|
|
Text: ((props: { children?: ReactNode }) => createElement("span", null, props.children)) as never,
|
|
},
|
|
bindings: {},
|
|
});
|
|
|
|
await act(async () => root.render(rendered.node));
|
|
|
|
expect(container.textContent).toBe("grass, poison · 200");
|
|
});
|
|
|
|
it("blocks reflective properties assembled with string concatenation", async () => {
|
|
await renderDisplay({
|
|
template: '<Text>{data["con" + "structor"]}</Text>',
|
|
data: { value: 1 },
|
|
});
|
|
|
|
expect(container.textContent).toContain("reflective property");
|
|
});
|
|
|
|
it.each([
|
|
'<Text>{data["constr\\u0075ctor"]}</Text>',
|
|
'<Text>{data["__pro" + "to__"]}</Text>',
|
|
'<Text>{String["b" + "ind"](null)}</Text>',
|
|
"<Text>{data.items.map.call(null, (item) => item)}</Text>",
|
|
"<Text>{data.items.map.apply(null, [])}</Text>",
|
|
])("blocks Unicode, computed, and call/apply reflective escapes", (template) => {
|
|
expect(() =>
|
|
renderSafeJsx({
|
|
template,
|
|
components: { Text: (() => null) as never },
|
|
bindings: SAFE_BINDINGS({ items: [1] }),
|
|
}),
|
|
).toThrow(/reflective property/);
|
|
});
|
|
|
|
it("never resolves inherited data properties", () => {
|
|
const inherited = Object.create({ secret: "leak" }) as Record<string, unknown>;
|
|
inherited.visible = "safe";
|
|
const bindings = SAFE_BINDINGS(inherited);
|
|
const data = bindings.data as Record<string, unknown>;
|
|
|
|
expect(data.visible).toBe("safe");
|
|
expect(data.secret).toBeUndefined();
|
|
expect(Object.getPrototypeOf(data)).toBeNull();
|
|
});
|
|
|
|
it("blocks direct global calls but allows benign text containing fetch", async () => {
|
|
await renderDisplay({ template: "<Text>fetch is available through controlled requests</Text>", data: {} });
|
|
expect(container.textContent).toContain("fetch is available through controlled requests");
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
createElement(
|
|
MantineProvider,
|
|
null,
|
|
createElement(CustomJsxDisplay, { data: { template: '<Text>{fetch("/private")}</Text>', data: {} } }),
|
|
),
|
|
);
|
|
});
|
|
expect(container.textContent).toContain("Unknown binding: fetch");
|
|
});
|
|
|
|
it("strips polymorphic, callback, unsafe URL, and escaping style props", () => {
|
|
const props = sanitizeCustomJsxProps({
|
|
component: "iframe",
|
|
renderRoot: "iframe",
|
|
withinPortal: true,
|
|
onClick: () => undefined,
|
|
href: "javascript:alert(1)",
|
|
style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" },
|
|
});
|
|
|
|
expect(props).toEqual({ style: { flex: 1, color: "red" } });
|
|
});
|
|
|
|
it("enforces collection and template length budgets", () => {
|
|
const bindings = SAFE_BINDINGS({ items: [1, 2, 3] });
|
|
|
|
expect(() =>
|
|
renderSafeJsx({
|
|
template: "<Stack>{data.items.map((item) => <Text>{item}</Text>)}</Stack>",
|
|
components: {
|
|
Text: ((props: { children?: unknown }) => props.children) as never,
|
|
Stack: (() => null) as never,
|
|
},
|
|
bindings,
|
|
budgets: { maxCollectionItems: 2 },
|
|
}),
|
|
).toThrow(/collection limit/);
|
|
|
|
expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow(
|
|
new SafeJsxError("Template exceeds the 50000 character limit"),
|
|
);
|
|
|
|
expect(() =>
|
|
renderSafeJsx({
|
|
template: '<Text>{"x".padStart(1000, "x")}</Text>',
|
|
components: { Text: (() => null) as never },
|
|
bindings: {},
|
|
budgets: { maxStringLength: 10 },
|
|
}),
|
|
).toThrow(/string length limit/);
|
|
|
|
expect(() =>
|
|
renderSafeJsx({
|
|
template: "<B/>".repeat(10_000),
|
|
components: { B: (() => null) as never },
|
|
bindings: {},
|
|
}),
|
|
).toThrow("Template exceeded the rendered node limit (10000)");
|
|
});
|
|
|
|
describe("safe bindings security", () => {
|
|
it("exposes wrapper functions, not native constructors", () => {
|
|
const bindings = SAFE_BINDINGS({ value: 7 });
|
|
|
|
expect(typeof bindings.String).toBe("function");
|
|
expect(bindings.String(42)).toBe("42");
|
|
expect(typeof bindings.Number).toBe("function");
|
|
expect(bindings.Number("42")).toBe(42);
|
|
expect(typeof bindings.Boolean).toBe("function");
|
|
expect(bindings.Boolean(1)).toBe(true);
|
|
|
|
expect(bindings.String).not.toBe(String);
|
|
expect(bindings.Number).not.toBe(Number);
|
|
expect(bindings.Boolean).not.toBe(Boolean);
|
|
});
|
|
|
|
it("uses null-prototype objects for Math/JSON/Array/Object helpers", () => {
|
|
const bindings = SAFE_BINDINGS({});
|
|
|
|
expect(Object.getPrototypeOf(bindings.Math)).toBeNull();
|
|
expect(Object.getPrototypeOf(bindings.JSON)).toBeNull();
|
|
expect(Object.getPrototypeOf(bindings.Array)).toBeNull();
|
|
expect(Object.getPrototypeOf(bindings.Object)).toBeNull();
|
|
});
|
|
|
|
it("sanitizes data to strip constructor/__proto__/prototype keys", () => {
|
|
const maliciousData = {
|
|
name: "safe",
|
|
constructor: "should be stripped",
|
|
__proto__: "should be stripped",
|
|
prototype: "should be stripped",
|
|
nested: {
|
|
value: 1,
|
|
constructor: "also stripped",
|
|
},
|
|
list: [{ constructor: "stripped", val: 2 }],
|
|
};
|
|
|
|
const bindings = SAFE_BINDINGS(maliciousData);
|
|
const data = bindings.data as Record<string, unknown>;
|
|
|
|
expect(data.name).toBe("safe");
|
|
expect("constructor" in data).toBe(false);
|
|
expect("__proto__" in data).toBe(false);
|
|
expect("prototype" in data).toBe(false);
|
|
|
|
const nested = data.nested as Record<string, unknown>;
|
|
expect(nested.value).toBe(1);
|
|
expect("constructor" in nested).toBe(false);
|
|
|
|
const list = data.list as Array<Record<string, unknown>>;
|
|
const firstItem = list[0] as Record<string, unknown>;
|
|
expect(firstItem.val).toBe(2);
|
|
expect("constructor" in firstItem).toBe(false);
|
|
});
|
|
|
|
it("data object has no prototype chain to Function", () => {
|
|
const bindings = SAFE_BINDINGS({ value: 1 });
|
|
const data = bindings.data as Record<string, unknown>;
|
|
|
|
expect(Object.getPrototypeOf(data)).toBeNull();
|
|
expect("constructor" in data).toBe(false);
|
|
});
|
|
|
|
it("does not expose eval, Function, fetch, window, document", () => {
|
|
const bindings = SAFE_BINDINGS({});
|
|
|
|
expect("eval" in bindings).toBe(false);
|
|
expect("Function" in bindings).toBe(false);
|
|
expect("fetch" in bindings).toBe(false);
|
|
expect("window" in bindings).toBe(false);
|
|
expect("document" in bindings).toBe(false);
|
|
});
|
|
|
|
it("Math helpers work correctly", () => {
|
|
const bindings = SAFE_BINDINGS({});
|
|
const math = bindings.Math as {
|
|
round: (v: number) => number;
|
|
floor: (v: number) => number;
|
|
ceil: (v: number) => number;
|
|
abs: (v: number) => number;
|
|
};
|
|
|
|
expect(math.round(3.7)).toBe(4);
|
|
expect(math.floor(3.7)).toBe(3);
|
|
expect(math.ceil(3.2)).toBe(4);
|
|
expect(math.abs(-5)).toBe(5);
|
|
});
|
|
|
|
it("Date helpers only return primitives and getDay returns the weekday", () => {
|
|
const bindings = SAFE_BINDINGS({});
|
|
const date = bindings.Date as {
|
|
create: (value: string) => number;
|
|
getDay: (value: string) => number;
|
|
};
|
|
|
|
expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number");
|
|
expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0);
|
|
});
|
|
|
|
it("JSON.stringify works correctly", () => {
|
|
const bindings = SAFE_BINDINGS({});
|
|
const json = bindings.JSON as { stringify: (v: unknown) => string };
|
|
|
|
expect(json.stringify({ a: 1 })).toBe('{"a":1}');
|
|
});
|
|
|
|
it("Object/Array helpers work correctly", () => {
|
|
const bindings = SAFE_BINDINGS({});
|
|
const obj = bindings.Object as {
|
|
keys: (v: object) => string[];
|
|
values: (v: object) => unknown[];
|
|
entries: (v: object) => [string, unknown][];
|
|
};
|
|
const arr = bindings.Array as { isArray: (v: unknown) => boolean };
|
|
|
|
expect(obj.keys({ a: 1, b: 2 })).toEqual(["a", "b"]);
|
|
expect(obj.values({ a: 1 })).toEqual([1]);
|
|
expect(arr.isArray([1, 2])).toBe(true);
|
|
expect(arr.isArray("not array")).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Custom JSX component registry", () => {
|
|
it.each([
|
|
["@mantine/core", MantineCore],
|
|
["@mantine/charts", MantineCharts],
|
|
["@mantine/dates", MantineDates],
|
|
] as const)("classifies every component-like %s export", (packageName, moduleExports) => {
|
|
const classified = new Set(
|
|
customJsxComponentRegistry
|
|
.filter((component) => component.package === packageName)
|
|
.flatMap(({ name }) => [name, name.replaceAll(".", "")]),
|
|
);
|
|
const unclassified = Object.entries(moduleExports)
|
|
.filter(isComponentLikeExport)
|
|
.map(([name]) => name)
|
|
.filter((name) => !classified.has(name));
|
|
|
|
expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]);
|
|
});
|
|
|
|
it("has a runtime implementation for every enabled registry component", () => {
|
|
const missing = enabledCustomJsxComponents
|
|
.map(({ name }) => name)
|
|
.filter((name) => !Object.hasOwn(WHITELISTED_COMPONENTS, name));
|
|
|
|
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
|
|
});
|
|
});
|
|
|
|
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
|
|
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
|
|
|
|
describe("new collection/string methods", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root?.unmount());
|
|
container?.remove();
|
|
});
|
|
|
|
const renderAndGetText = async (template: string, data: Record<string, unknown> = {}) => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } })));
|
|
});
|
|
return container.textContent ?? "";
|
|
};
|
|
|
|
it("find returns the first matching item", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
|
|
items: [
|
|
{ id: 1, name: "a" },
|
|
{ id: 2, name: "b" },
|
|
],
|
|
});
|
|
expect(text).toContain("b");
|
|
});
|
|
|
|
it("findIndex returns correct index", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.findIndex((x) => x > 5)}</Text>`, { items: [1, 3, 7, 9] });
|
|
expect(text).toContain("2");
|
|
});
|
|
|
|
it("some returns boolean", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.some((x) => x > 5) ? "yes" : "no"}</Text>`, {
|
|
items: [1, 2, 3],
|
|
});
|
|
expect(text).toContain("no");
|
|
});
|
|
|
|
it("every returns boolean", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.every((x) => x > 0) ? "all" : "nope"}</Text>`, {
|
|
items: [1, 2, 3],
|
|
});
|
|
expect(text).toContain("all");
|
|
});
|
|
|
|
it("sort with comparator returns stable order on NaN", async () => {
|
|
const text = await renderAndGetText(
|
|
`<Text>{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}</Text>`,
|
|
{ items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] },
|
|
);
|
|
expect(text).toContain("a,");
|
|
expect(text).toContain("b");
|
|
});
|
|
|
|
it("reduce accumulates values", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.nums.reduce((acc, n) => acc + n, 0)}</Text>`, {
|
|
nums: [1, 2, 3],
|
|
});
|
|
expect(text).toContain("6");
|
|
});
|
|
|
|
it("at returns element by negative index", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.at(-1)}</Text>`, { items: ["a", "b", "c"] });
|
|
expect(text).toContain("c");
|
|
});
|
|
|
|
it("flat(2) throws SafeJsxError", () => {
|
|
expect(() => renderTemplate(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
|
|
});
|
|
|
|
it("flat(1) flattens one level", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.flat().join(",")}</Text>`, { items: [[1, 2], [3]] });
|
|
expect(text).toContain("1,2,3");
|
|
});
|
|
|
|
it("reverse returns reversed copy", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.reverse().join(",")}</Text>`, { items: [1, 2, 3] });
|
|
expect(text).toContain("3,2,1");
|
|
});
|
|
|
|
it("string replaceAll works", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.s.replaceAll("o", "0")}</Text>`, { s: "foobar" });
|
|
expect(text).toContain("f00bar");
|
|
});
|
|
|
|
it("string replaceAll with empty search is a no-op", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.s.replaceAll("", "x")}</Text>`, { s: "abc" });
|
|
expect(text).toContain("abc");
|
|
});
|
|
|
|
it("string repeat caps at budget", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.s.repeat(3)}</Text>`, { s: "hi" });
|
|
expect(text).toContain("hihihi");
|
|
});
|
|
|
|
it("string indexOf and lastIndexOf return positions", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}</Text>`, {
|
|
s: "hello",
|
|
});
|
|
expect(text).toContain("2-3");
|
|
});
|
|
|
|
it("string trimStart and trimEnd work", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.s.trimStart().trimEnd()}</Text>`, { s: " hi " });
|
|
expect(text).toContain("hi");
|
|
});
|
|
|
|
it("filter(Boolean) removes falsy values", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.items.filter(Boolean).join(",")}</Text>`, {
|
|
items: ["a", "", "b", null, "c"],
|
|
});
|
|
expect(text).toContain("a,b,c");
|
|
});
|
|
|
|
it("pop returns last element", async () => {
|
|
const text = await renderAndGetText(`<Text>{data.url.split("/").filter(Boolean).pop()}</Text>`, {
|
|
url: "https://pokeapi.co/api/v2/pokemon/1/",
|
|
});
|
|
expect(text).toContain("1");
|
|
});
|
|
|
|
it("String(n).padStart works for number formatting", async () => {
|
|
const text = await renderAndGetText(`<Text>{String(data.n + 1).padStart(4, "0")}</Text>`, { n: 5 });
|
|
expect(text).toContain("0006");
|
|
});
|
|
|
|
it("number arithmetic in string concatenation", async () => {
|
|
const text = await renderAndGetText(`<Text>{"https://example.com/" + (data.index + 1) + ".png"}</Text>`, {
|
|
index: 3,
|
|
});
|
|
expect(text).toContain("https://example.com/4.png");
|
|
});
|
|
});
|
|
|
|
describe("shared Custom JSX examples", () => {
|
|
const sampleData: Record<string, unknown> = {
|
|
name: "Server A",
|
|
status: "online",
|
|
value: 42,
|
|
count: 1351,
|
|
results: [{ name: "bulbasaur" }, { name: "ivysaur" }],
|
|
holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }],
|
|
timeline: [
|
|
{ date: "2026-07-11", value: 40 },
|
|
{ date: "2026-07-12", value: 42.5 },
|
|
],
|
|
albumArt: "https://example.com/cover.jpg",
|
|
album: "Album",
|
|
title: "Track",
|
|
artist: "Artist",
|
|
progress: 55,
|
|
playing: true,
|
|
shuffle: false,
|
|
state: "on",
|
|
};
|
|
|
|
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
|
|
const rendered = renderSafeJsx({
|
|
template: example.template,
|
|
components: WHITELISTED_COMPONENTS,
|
|
bindings: SAFE_BINDINGS(sampleData),
|
|
});
|
|
|
|
expect(rendered.warnings).toEqual([]);
|
|
expect(rendered.node).not.toBeNull();
|
|
});
|
|
});
|