Files
homarr/packages/widgets/src/custom-api/custom-jsx-display.spec.ts
T
2026-06-10 18:58:14 +02:00

179 lines
6.0 KiB
TypeScript

import { MantineProvider } from "@mantine/core";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import type { Root } from "react-dom/client";
import CustomJsxDisplay from "./custom-jsx-display";
import { SAFE_BINDINGS } from "./jsx-whitelist";
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(() => {
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");
});
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("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);
});
});
});