fix(custom-widgets): address v2 merge review
This commit is contained in:
1 parent
6aceb2fd1f
commit
48ce83806d
122 files changed
+8534
-1447
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN } from "../jsx/policy";
|
||||
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
|
||||
import { customWidgetOptionsSchema } from "./options-schema";
|
||||
import {
|
||||
@@ -20,7 +21,10 @@ export const customWidgetBindingIdentifierSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
|
||||
.regex(
|
||||
CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN,
|
||||
"Binding names must start with a letter and contain letters, numbers, - or _",
|
||||
);
|
||||
|
||||
export const customJsxTemplateSchema = z
|
||||
.string()
|
||||
@@ -77,8 +81,8 @@ export const customWidgetDefinitionSchema = z
|
||||
})
|
||||
.superRefine((definition, ctx) => {
|
||||
validateRequests(definition, ctx);
|
||||
validateCaseInsensitiveSourceIds(definition.sources, ctx);
|
||||
validateTemplateRequests(definition.template, definition.requests, ctx);
|
||||
validateTemplateBindings(definition.template, ctx);
|
||||
validateCredentialFreeExport(definition, ctx);
|
||||
validatePrototypeKeys(definition, ctx);
|
||||
});
|
||||
@@ -144,6 +148,23 @@ function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateCaseInsensitiveSourceIds(sources: Definition["sources"], ctx: z.RefinementCtx) {
|
||||
const seen = new Map<string, string>();
|
||||
for (const sourceId of Object.keys(sources)) {
|
||||
const normalized = sourceId.toLowerCase();
|
||||
const existing = seen.get(normalized);
|
||||
if (existing) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["sources", sourceId],
|
||||
message: `Source ID '${sourceId}' conflicts case-insensitively with '${existing}'`,
|
||||
});
|
||||
} else {
|
||||
seen.set(normalized, sourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateRequests(template: string, requests: Record<string, CustomJsxRequest>, ctx: z.RefinementCtx) {
|
||||
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/gu)) {
|
||||
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
|
||||
@@ -169,15 +190,6 @@ function validateTemplateRequests(template: string, requests: Record<string, Cus
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateBindings(template: string, ctx: z.RefinementCtx) {
|
||||
for (const match of template.matchAll(/<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) {
|
||||
const inputName = match[1] ?? match[2];
|
||||
if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" });
|
||||
else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success)
|
||||
ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` });
|
||||
}
|
||||
}
|
||||
|
||||
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
|
||||
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
|
||||
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import type { AstNode } from "./analyzer-ast";
|
||||
import { containsEscapingCallback, nodeOf, nodesOf } from "./analyzer-ast";
|
||||
import { closestCustomJsxComponentName, customJsxTagName, isSafeLiteralCustomJsxUrl } from "./analyzer-language";
|
||||
import { CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
|
||||
import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN, CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
|
||||
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
|
||||
|
||||
interface AnalyzerJsxContext {
|
||||
@@ -75,6 +75,7 @@ export function analyzeCustomJsxElement(
|
||||
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
|
||||
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
|
||||
}
|
||||
if (attributeName === "bind") analyzeBindAttribute(attribute, context);
|
||||
analyzeAttributeValue(resolvedName, attributeName, attribute, depth, bindings, context);
|
||||
}
|
||||
|
||||
@@ -88,6 +89,15 @@ export function analyzeCustomJsxElement(
|
||||
});
|
||||
}
|
||||
|
||||
function analyzeBindAttribute(attribute: AstNode, context: AnalyzerJsxContext): void {
|
||||
const value = nodeOf(attribute.value);
|
||||
if (value?.type !== "Literal" || typeof value.value !== "string") {
|
||||
context.add(attribute, "bind must use a literal input name");
|
||||
} else if (!CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN.test(value.value)) {
|
||||
context.add(attribute, `Invalid bind input name '${value.value}'`);
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeAttributeValue(
|
||||
componentName: string | null,
|
||||
attributeName: string,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
CUSTOM_JSX_CALLBACK_METHODS,
|
||||
CUSTOM_JSX_SAFE_VALUE_METHODS,
|
||||
} from "./safe-language-policy";
|
||||
import { isSafeRegexLiteral } from "./regex-policy";
|
||||
|
||||
export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
|
||||
|
||||
@@ -153,7 +154,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
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 bounded regex without lookbehind, backreferences, or nested quantifiers");
|
||||
add(node, "UNSAFE_REGEX: Use a conservative regex without repeated alternatives or variable quantifiers");
|
||||
return;
|
||||
case "Identifier": {
|
||||
const name = String(node.name ?? "");
|
||||
@@ -232,7 +233,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
}
|
||||
case "CallExpression": {
|
||||
const callee = nodeOf(node.callee);
|
||||
const arguments_ = nodesOf(node.arguments);
|
||||
const callArguments = nodesOf(node.arguments);
|
||||
if (node.optional) {
|
||||
add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported");
|
||||
}
|
||||
@@ -251,7 +252,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
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];
|
||||
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") {
|
||||
@@ -260,7 +261,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`);
|
||||
}
|
||||
}
|
||||
arguments_.forEach((argument, argumentIndex) => {
|
||||
callArguments.forEach((argument, argumentIndex) => {
|
||||
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
|
||||
else if (
|
||||
argument.type === "ArrowFunctionExpression" &&
|
||||
@@ -318,11 +319,3 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function isSafeRegexLiteral(regex: unknown): boolean {
|
||||
if (!regex || typeof regex !== "object") return false;
|
||||
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
|
||||
if (typeof pattern !== "string" || pattern.length > 128 || typeof flags !== "string" || /[^gimsu]/u.test(flags))
|
||||
return false;
|
||||
return !/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(pattern);
|
||||
}
|
||||
@@ -1,14 +1,85 @@
|
||||
import { createSafeCallable } from "./safe-bindings";
|
||||
import { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
|
||||
|
||||
export const CUSTOM_JSX_DATA_LIMITS = Object.freeze({
|
||||
depth: 32,
|
||||
nodes: 50_000,
|
||||
stringLength: 200_000,
|
||||
});
|
||||
|
||||
interface SanitizeTask {
|
||||
value: unknown;
|
||||
depth: number;
|
||||
assign(value: unknown): void;
|
||||
}
|
||||
|
||||
function sanitizeData(value: unknown): unknown {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(sanitizeData);
|
||||
const safe: Record<string, unknown> = Object.create(null);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
|
||||
safe[key] = sanitizeData(child);
|
||||
let result: unknown;
|
||||
let nodes = 0;
|
||||
const seen = new WeakSet<object>();
|
||||
const tasks: SanitizeTask[] = [{ value, depth: 0, assign: (sanitized) => (result = sanitized) }];
|
||||
|
||||
while (tasks.length > 0) {
|
||||
const task = tasks.pop();
|
||||
if (!task) break;
|
||||
if (++nodes > CUSTOM_JSX_DATA_LIMITS.nodes) {
|
||||
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
|
||||
}
|
||||
if (task.depth > CUSTOM_JSX_DATA_LIMITS.depth) {
|
||||
throw new SafeJsxBudgetError(`Response data exceeded the depth limit (${CUSTOM_JSX_DATA_LIMITS.depth})`);
|
||||
}
|
||||
if (typeof task.value === "string" && task.value.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
|
||||
throw new SafeJsxBudgetError(
|
||||
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
|
||||
);
|
||||
}
|
||||
if (task.value === null || typeof task.value !== "object") {
|
||||
task.assign(task.value);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(task.value)) throw new SafeJsxError("Response data must not contain circular or repeated objects");
|
||||
seen.add(task.value);
|
||||
|
||||
if (Array.isArray(task.value)) {
|
||||
if (task.value.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
|
||||
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
|
||||
}
|
||||
const safe = Array.from<unknown>({ length: task.value.length });
|
||||
task.assign(safe);
|
||||
for (let index = task.value.length - 1; index >= 0; index -= 1) {
|
||||
if (!Object.hasOwn(task.value, index)) continue;
|
||||
tasks.push({
|
||||
value: task.value[index],
|
||||
depth: task.depth + 1,
|
||||
assign: (sanitized) => (safe[index] = sanitized),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = Object.entries(task.value);
|
||||
if (entries.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
|
||||
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
|
||||
}
|
||||
const safe: Record<string, unknown> = Object.create(null);
|
||||
task.assign(safe);
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const [key, child] = entries[index] as [string, unknown];
|
||||
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
|
||||
if (key.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
|
||||
throw new SafeJsxBudgetError(
|
||||
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
|
||||
);
|
||||
}
|
||||
tasks.push({
|
||||
value: child,
|
||||
depth: task.depth + 1,
|
||||
assign: (sanitized) => (safe[key] = sanitized),
|
||||
});
|
||||
}
|
||||
}
|
||||
return safe;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const safeMath = Object.freeze(
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets }
|
||||
import { normalizeCustomJsxText, parseCustomJsxTemplate } from "./interpreter-parser";
|
||||
import { normalizedProperty, ownProperty } from "./safe-properties";
|
||||
import { CUSTOM_JSX_CALLBACK_METHODS } from "./safe-language-policy";
|
||||
import { isSafeRegexLiteral } from "./regex-policy";
|
||||
|
||||
export { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
|
||||
export type { SafeJsxBudgets } from "./interpreter-foundation";
|
||||
@@ -51,16 +52,10 @@ class Interpreter {
|
||||
case "Literal":
|
||||
if (node.regex !== undefined) {
|
||||
const regex = node.regex as { pattern?: unknown; flags?: unknown };
|
||||
if (
|
||||
typeof regex.pattern !== "string" ||
|
||||
regex.pattern.length > 128 ||
|
||||
typeof regex.flags !== "string" ||
|
||||
/[^gimsu]/u.test(regex.flags) ||
|
||||
/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(regex.pattern)
|
||||
) {
|
||||
if (!isSafeRegexLiteral(regex)) {
|
||||
throw new SafeJsxError("UNSAFE_REGEX: Invalid regular expression literal");
|
||||
}
|
||||
return new RegExp(regex.pattern, regex.flags);
|
||||
return new RegExp(regex.pattern as string, regex.flags as string);
|
||||
}
|
||||
if (typeof node.value === "bigint")
|
||||
throw new SafeJsxError("BIGINT_NOT_SUPPORTED: Use Number for widget values");
|
||||
|
||||
@@ -43,7 +43,12 @@ export function emitJsxElement(
|
||||
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
|
||||
if (attribute.type === "JSXSpreadAttribute") {
|
||||
const spread = context.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
|
||||
if (spread && typeof spread === "object" && !Array.isArray(spread)) Object.assign(rawProps, spread);
|
||||
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
|
||||
if (Object.hasOwn(spread, "bind")) {
|
||||
throw new SafeJsxError("BIND_SPREAD_NOT_ALLOWED: The bind prop must be an explicit literal JSX attribute");
|
||||
}
|
||||
Object.assign(rawProps, spread);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
|
||||
|
||||
@@ -7,6 +7,8 @@ export const CUSTOM_JSX_LIMITS = Object.freeze({
|
||||
stringLength: 200_000,
|
||||
});
|
||||
|
||||
export const CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/u;
|
||||
|
||||
export const CUSTOM_JSX_BLOCKED_PROPERTIES: ReadonlySet<string> = new Set([
|
||||
"__proto__",
|
||||
"apply",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
interface RegexGroupState {
|
||||
atomCount: number;
|
||||
containsAlternation: boolean;
|
||||
containsQuantifier: boolean;
|
||||
}
|
||||
|
||||
interface RegexQuantifier {
|
||||
nextIndex: number;
|
||||
variable: boolean;
|
||||
}
|
||||
|
||||
const MAX_REGEX_PATTERN_LENGTH = 128;
|
||||
const MAX_EXACT_REPETITION = 1_000;
|
||||
|
||||
/**
|
||||
* JavaScript's regular-expression engine can backtrack exponentially. Keep authored
|
||||
* expressions in a deliberately small, linear-time subset: no lookarounds or
|
||||
* backreferences, no repeated alternatives/groups, and at most one quantifier that
|
||||
* can consume a variable number of characters in the whole expression.
|
||||
*/
|
||||
export function isSafeRegexLiteral(regex: unknown): boolean {
|
||||
if (!regex || typeof regex !== "object") return false;
|
||||
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
|
||||
if (
|
||||
typeof pattern !== "string" ||
|
||||
pattern.length > MAX_REGEX_PATTERN_LENGTH ||
|
||||
typeof flags !== "string" ||
|
||||
/[^gimsu]/u.test(flags)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isSafeRegexPattern(pattern);
|
||||
}
|
||||
|
||||
function isSafeRegexPattern(pattern: string): boolean {
|
||||
const groups: RegexGroupState[] = [createGroupState()];
|
||||
let variableQuantifiers = 0;
|
||||
let index = 0;
|
||||
|
||||
const consumeAtom = (nested?: RegexGroupState): boolean => {
|
||||
const parent = groups.at(-1);
|
||||
if (!parent) return false;
|
||||
parent.atomCount += 1;
|
||||
if (nested) {
|
||||
parent.containsAlternation ||= nested.containsAlternation;
|
||||
parent.containsQuantifier ||= nested.containsQuantifier;
|
||||
}
|
||||
const quantifier = readQuantifier(pattern, index);
|
||||
if (!quantifier) return true;
|
||||
if (quantifier.nextIndex > pattern.length) return false;
|
||||
if (nested && (nested.atomCount === 0 || nested.containsAlternation || nested.containsQuantifier)) return false;
|
||||
parent.containsQuantifier = true;
|
||||
if (quantifier.variable && ++variableQuantifiers > 1) return false;
|
||||
index = quantifier.nextIndex;
|
||||
return true;
|
||||
};
|
||||
|
||||
while (index < pattern.length) {
|
||||
const character = pattern[index];
|
||||
if (character === "\\") {
|
||||
const escaped = pattern[index + 1];
|
||||
if (escaped === undefined || /[1-9k]/u.test(escaped)) return false;
|
||||
index += 2;
|
||||
if (!consumeAtom()) return false;
|
||||
continue;
|
||||
}
|
||||
if (character === "[") {
|
||||
index += 1;
|
||||
let closed = false;
|
||||
while (index < pattern.length) {
|
||||
if (pattern[index] === "\\") index += 2;
|
||||
else {
|
||||
const classCharacter = pattern[index++];
|
||||
if (classCharacter === "]") {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!closed || !consumeAtom()) return false;
|
||||
continue;
|
||||
}
|
||||
if (character === "(") {
|
||||
if (pattern[index + 1] === "?") return false;
|
||||
groups.push(createGroupState());
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === ")") {
|
||||
if (groups.length === 1) return false;
|
||||
const nested = groups.pop();
|
||||
index += 1;
|
||||
if (!nested || !consumeAtom(nested)) return false;
|
||||
continue;
|
||||
}
|
||||
if (character === "|") {
|
||||
const current = groups.at(-1);
|
||||
if (!current) return false;
|
||||
current.containsAlternation = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "^" || character === "$") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "*" || character === "+" || character === "?" || character === "{") {
|
||||
// A valid quantifier is consumed with its atom. Reaching one here means it is
|
||||
// malformed or is an unsupported literal brace construct.
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
if (!consumeAtom()) return false;
|
||||
}
|
||||
|
||||
return groups.length === 1;
|
||||
}
|
||||
|
||||
function createGroupState(): RegexGroupState {
|
||||
return { atomCount: 0, containsAlternation: false, containsQuantifier: false };
|
||||
}
|
||||
|
||||
function readQuantifier(pattern: string, index: number): RegexQuantifier | null {
|
||||
const character = pattern[index];
|
||||
if (character === "*" || character === "+" || character === "?") {
|
||||
const nextIndex = pattern[index + 1] === "?" ? index + 2 : index + 1;
|
||||
return { nextIndex, variable: true };
|
||||
}
|
||||
if (character !== "{") return null;
|
||||
const match = /^\{(\d+)(?:,(\d*))?\}/u.exec(pattern.slice(index));
|
||||
if (!match) return null;
|
||||
const minimum = Number(match[1]);
|
||||
const maximum = match[2] === undefined || match[2] === "" ? undefined : Number(match[2]);
|
||||
if (
|
||||
!Number.isSafeInteger(minimum) ||
|
||||
minimum > MAX_EXACT_REPETITION ||
|
||||
(maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum > MAX_EXACT_REPETITION))
|
||||
) {
|
||||
return { nextIndex: pattern.length + 1, variable: true };
|
||||
}
|
||||
let nextIndex = index + match[0].length;
|
||||
if (pattern[nextIndex] === "?") nextIndex += 1;
|
||||
return { nextIndex, variable: match[2] !== undefined && maximum !== minimum };
|
||||
}
|
||||
@@ -162,19 +162,24 @@ function CustomJsxRendererSession({
|
||||
},
|
||||
[inputTypes],
|
||||
);
|
||||
const bindings = useMemo(
|
||||
() => ({ ...createBindings(data), status, options, inputs }),
|
||||
[createBindings, data, inputs, options, status],
|
||||
);
|
||||
const boundaryKey = useMemo(() => createBoundaryKey(template, bindings), [bindings, template]);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
return { ...renderSafeJsx({ template, components, bindings }), error: null };
|
||||
const bindings = { ...createBindings(data), status, options, inputs };
|
||||
return {
|
||||
...renderSafeJsx({ template, components, bindings }),
|
||||
boundaryKey: createBoundaryKey(template, bindings),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { node: null, warnings: [], error: error instanceof Error ? error : new Error(String(error)) };
|
||||
return {
|
||||
node: null,
|
||||
warnings: [],
|
||||
boundaryKey: `${template.length}:error`,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}, [bindings, components, template]);
|
||||
useEffect(() => setParseErrors([]), [bindings, template]);
|
||||
}, [components, createBindings, data, inputs, options, status, template]);
|
||||
useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]);
|
||||
const handleError = useCallback(
|
||||
(error: Error) =>
|
||||
setParseErrors((current) => {
|
||||
@@ -201,7 +206,7 @@ function CustomJsxRendererSession({
|
||||
registerInput={registerInput}
|
||||
setInputValue={setInputValue}
|
||||
>
|
||||
<RendererErrorBoundary resetKey={boundaryKey} onError={handleError}>
|
||||
<RendererErrorBoundary resetKey={rendered.boundaryKey} onError={handleError}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
</CustomJsxInputsProvider>
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface SubFetchProps {
|
||||
trigger?: "auto" | "manual";
|
||||
}
|
||||
|
||||
// Browser timers use signed 32-bit millisecond delays. Whole-second refresh
|
||||
// intervals are capped at the largest value that remains below that boundary.
|
||||
export const MAX_REFRESH_INTERVAL_SECONDS = 2_147_483;
|
||||
export const MAX_REFRESH_INTERVAL_MS = MAX_REFRESH_INTERVAL_SECONDS * 1_000;
|
||||
|
||||
export function SubFetch(props: SubFetchProps) {
|
||||
const { itemId, previewSessionId, queriesDisabled, port, messages, setQueryState } = useCustomWidgetRuntime();
|
||||
const [manualRun, setManualRun] = useState(false);
|
||||
@@ -155,8 +160,9 @@ export function SubFetch(props: SubFetchProps) {
|
||||
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
|
||||
}
|
||||
|
||||
function normalizeRefreshInterval(value: number | undefined): number | false {
|
||||
return value && Number.isFinite(value) && value > 0 ? Math.max(1_000, value * 1_000) : false;
|
||||
export function normalizeRefreshInterval(value: number | undefined): number | false {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return false;
|
||||
return Math.max(1_000, Math.min(value, MAX_REFRESH_INTERVAL_SECONDS) * 1_000);
|
||||
}
|
||||
|
||||
function renderContent(props: SubFetchProps, data: unknown, metadata: SubFetchMetadata) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type CustomWidgetDomainErrorCode =
|
||||
| "BAD_GATEWAY"
|
||||
| "BAD_REQUEST"
|
||||
| "CONFLICT"
|
||||
| "FORBIDDEN"
|
||||
| "INTERNAL_SERVER_ERROR"
|
||||
| "NOT_FOUND"
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest,
|
||||
invalidateCustomWidgetResponseCache,
|
||||
MAX_REQUEST_DURATION_MS,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
@@ -15,7 +16,7 @@ export {
|
||||
validateCustomWidgetUrl,
|
||||
} from "./request-executor";
|
||||
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
|
||||
export { CustomWidgetRequestLimiter } from "./request-limits";
|
||||
export { CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS, CustomWidgetRequestLimiter } from "./request-limits";
|
||||
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
|
||||
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
|
||||
export type {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
const SESSION_TTL_MS = 10 * 60_000;
|
||||
const MAX_JOURNAL_ENTRIES = 50;
|
||||
const MAX_UPDATE_ATTEMPTS = 8;
|
||||
|
||||
const encryptedSecretSchema = z.object({
|
||||
sourceId: z.string(),
|
||||
@@ -22,6 +23,7 @@ const encryptedSecretSchema = z.object({
|
||||
const sessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
revision: z.number().int().nonnegative(),
|
||||
expiresAt: z.number(),
|
||||
sources: customWidgetSourcesSchema,
|
||||
secrets: z.array(encryptedSecretSchema),
|
||||
@@ -62,6 +64,7 @@ export interface CreatePreviewSessionInput {
|
||||
|
||||
export interface PreviewSessionStore {
|
||||
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
|
||||
getSession(id: string): Promise<unknown>;
|
||||
deleteSession(id: string): Promise<void>;
|
||||
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
|
||||
@@ -96,6 +99,7 @@ export class CustomWidgetPreviewSessionService {
|
||||
const session: CustomWidgetPreviewSession = {
|
||||
id: this.options.createId(),
|
||||
userId: input.userId,
|
||||
revision: 0,
|
||||
expiresAt: this.now() + SESSION_TTL_MS,
|
||||
sources: input.sources,
|
||||
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
@@ -114,17 +118,19 @@ export class CustomWidgetPreviewSessionService {
|
||||
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
|
||||
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
|
||||
const parsed = sessionSchema.safeParse(parseStoredValue(candidate));
|
||||
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= this.now()) {
|
||||
if (!parsed.success || parsed.data.expiresAt <= this.now()) {
|
||||
if (this.options.store) await this.options.store.deleteSession(id);
|
||||
else this.sessions.delete(id);
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
if (parsed.data.userId !== userId) {
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
public async setLiveActions(id: string, userId: string, enabled: boolean) {
|
||||
const session = { ...(await this.get(id, userId)), liveActions: enabled };
|
||||
await this.save(session);
|
||||
const session = await this.update(id, userId, (current) => ({ ...current, liveActions: enabled }));
|
||||
return { id, expiresAt: session.expiresAt, liveActions: enabled };
|
||||
}
|
||||
|
||||
@@ -160,23 +166,23 @@ export class CustomWidgetPreviewSessionService {
|
||||
userId: string,
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
||||
) {
|
||||
const session = await this.get(id, userId);
|
||||
const sourceIds = new Set(Object.keys(session.sources));
|
||||
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A preview secret references an unknown source",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
const next = {
|
||||
...session,
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
await this.save(next);
|
||||
const next = await this.update(id, userId, (session) => {
|
||||
const sourceIds = new Set(Object.keys(session.sources));
|
||||
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A preview secret references an unknown source",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
...session,
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
});
|
||||
return { id, expiresAt: next.expiresAt };
|
||||
}
|
||||
|
||||
@@ -187,33 +193,53 @@ export class CustomWidgetPreviewSessionService {
|
||||
source: CustomWidgetSource,
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
||||
) {
|
||||
const session = await this.get(id, userId);
|
||||
const currentSource = session.sources[sourceId];
|
||||
if (!currentSource) {
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
}
|
||||
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Preview source authentication changed; create a new configuration request",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
const next = {
|
||||
...session,
|
||||
sources: {
|
||||
...session.sources,
|
||||
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
|
||||
},
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
await this.save(next);
|
||||
const next = await this.update(id, userId, (session) => {
|
||||
const currentSource = session.sources[sourceId];
|
||||
if (!currentSource) {
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
}
|
||||
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Preview source authentication changed; create a new configuration request",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
...session,
|
||||
sources: {
|
||||
...session.sources,
|
||||
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
|
||||
},
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
});
|
||||
return { id, expiresAt: next.expiresAt };
|
||||
}
|
||||
|
||||
private async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
mutate: (session: CustomWidgetPreviewSession) => CustomWidgetPreviewSession,
|
||||
) {
|
||||
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS; attempt += 1) {
|
||||
const current = await this.get(id, userId);
|
||||
const next = { ...mutate(current), revision: current.revision + 1 };
|
||||
const ttlMs = Math.max(0, next.expiresAt - this.now());
|
||||
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
if (this.options.store) {
|
||||
if (await this.options.store.compareAndSwapSession(id, current.revision, next, ttlMs)) return next;
|
||||
} else if (this.sessions.get(id)?.revision === current.revision) {
|
||||
this.sessions.set(id, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
throw new CustomWidgetDomainError({ code: "CONFLICT", message: "Preview session changed too many times; retry" });
|
||||
}
|
||||
|
||||
private async save(session: CustomWidgetPreviewSession): Promise<void> {
|
||||
const ttlMs = Math.max(0, session.expiresAt - this.now());
|
||||
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
|
||||
@@ -32,6 +32,7 @@ export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
|
||||
export const MAX_REQUEST_DURATION_MS = 45_000;
|
||||
|
||||
export interface CustomWidgetAuthConfig {
|
||||
type: string;
|
||||
@@ -64,6 +65,29 @@ const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
let cacheEpoch = 0;
|
||||
|
||||
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
|
||||
try {
|
||||
return await performRequestWithinDeadline(input, controller.signal);
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
if (controller.signal.aborted) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "External request exceeded the total time limit",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function performRequestWithinDeadline(
|
||||
input: CustomWidgetHttpRequest,
|
||||
deadlineSignal: AbortSignal,
|
||||
): Promise<CustomWidgetHttpResponse> {
|
||||
assertRequest(input);
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
@@ -86,7 +110,7 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
method: currentMethod,
|
||||
headers,
|
||||
body: currentBody,
|
||||
signal: controller.signal,
|
||||
signal: AbortSignal.any([deadlineSignal, controller.signal]),
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
const CONCURRENCY_TTL_MS = 30_000;
|
||||
// A query may legally follow three redirects with a ten-second timeout per hop.
|
||||
// Keep the lease above that total and identify every acquisition so a late
|
||||
// release can never remove capacity owned by a newer request.
|
||||
export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000;
|
||||
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const;
|
||||
type RequestCategory = keyof typeof CATEGORY_LIMITS;
|
||||
|
||||
@@ -16,8 +19,8 @@ export interface RequestLimitInput {
|
||||
|
||||
export interface RequestLimitStore {
|
||||
incrementRate(key: string, windowMs: number): Promise<{ count: number; retryAfterMs: number }>;
|
||||
acquireConcurrency(key: string, limit: number, ttlMs: number): Promise<boolean>;
|
||||
releaseConcurrency(key: string): Promise<void>;
|
||||
acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number): Promise<boolean>;
|
||||
releaseConcurrency(key: string, ownerId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RequestLimiterOptions {
|
||||
@@ -34,8 +37,8 @@ export class CustomWidgetRequestLimiter {
|
||||
public async acquire(input: RequestLimitInput): Promise<() => Promise<void>> {
|
||||
const identity = input.userId ?? "anonymous";
|
||||
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
|
||||
const userKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
|
||||
const userKey = `custom-widget:concurrency:v2:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionKey = `custom-widget:concurrency:v2:definition:${hashKey(input.definitionId)}`;
|
||||
if (!this.options.store) return this.acquireLocal(rateKey, userKey, definitionKey, input.category);
|
||||
try {
|
||||
return await this.acquireStored(rateKey, userKey, definitionKey, input.category, this.options.store);
|
||||
@@ -76,17 +79,22 @@ export class CustomWidgetRequestLimiter {
|
||||
category: RequestCategory,
|
||||
store: RequestLimitStore,
|
||||
) {
|
||||
const ownerId = randomUUID();
|
||||
const rate = await store.incrementRate(rateKey, WINDOW_MS);
|
||||
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
|
||||
if (!(await store.acquireConcurrency(userKey, 4, CONCURRENCY_TTL_MS))) throwLimit(1_000);
|
||||
if (!(await store.acquireConcurrency(userKey, ownerId, 4, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
|
||||
throwLimit(1_000);
|
||||
}
|
||||
try {
|
||||
if (!(await store.acquireConcurrency(definitionKey, 8, CONCURRENCY_TTL_MS))) throwLimit(1_000);
|
||||
if (!(await store.acquireConcurrency(definitionKey, ownerId, 8, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
|
||||
throwLimit(1_000);
|
||||
}
|
||||
} catch (error) {
|
||||
await store.releaseConcurrency(userKey);
|
||||
await store.releaseConcurrency(userKey, ownerId);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
await Promise.all([store.releaseConcurrency(userKey), store.releaseConcurrency(definitionKey)]);
|
||||
await Promise.all([store.releaseConcurrency(userKey, ownerId), store.releaseConcurrency(definitionKey, ownerId)]);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { createCustomJsxBindings } from "../jsx/bindings";
|
||||
import { createCustomJsxBindings, CUSTOM_JSX_DATA_LIMITS } from "../jsx/bindings";
|
||||
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
|
||||
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
|
||||
|
||||
@@ -221,6 +221,37 @@ describe("shared Custom JSX policy", () => {
|
||||
expect(diagnostics.some(({ message }) => message.includes("Prop 'bind'"))).toBe(false);
|
||||
});
|
||||
|
||||
test("validates explicit bind attributes through the JSX AST", () => {
|
||||
expect(
|
||||
validateCustomJsxTemplate('<TextInput bind="search" />').filter(({ severity }) => severity === "error"),
|
||||
).toEqual([]);
|
||||
expect(validateCustomJsxTemplate("<TextInput bind={data.name} />")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ severity: "error", message: expect.stringContaining("literal input name") }),
|
||||
]),
|
||||
);
|
||||
expect(validateCustomJsxTemplate('<TextInput bind="not valid" />')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ severity: "error", message: expect.stringContaining("Invalid bind input name") }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
validateCustomJsxTemplate('<Text>{"<TextInput bind={data.name} />"}</Text>').some(({ message }) =>
|
||||
message.includes("literal input name"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects bind values introduced by JSX spreads at the emitter boundary", () => {
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template: "<TextInput {...data.props} />",
|
||||
components: { TextInput: (() => null) as never },
|
||||
bindings: { data: { props: { bind: "remote-name", label: "Unsafe" } } },
|
||||
}),
|
||||
).toThrow("BIND_SPREAD_NOT_ALLOWED");
|
||||
});
|
||||
|
||||
test("provides closest component and binding diagnostics", () => {
|
||||
expect(validateCustomJsxTemplate("<Stak />")[0]?.message).toMatch(/UNKNOWN_COMPONENT.*Stack/u);
|
||||
expect(validateCustomJsxTemplate('<Text bind="search" />')).toEqual(
|
||||
@@ -305,9 +336,34 @@ describe("shared Custom JSX policy", () => {
|
||||
expect(renderToStaticMarkup(rendered.node)).toContain("Bulba-saur-");
|
||||
});
|
||||
|
||||
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/"])("rejects unsafe regex %s", (pattern) => {
|
||||
expect(validateCustomJsxTemplate(`<Text>{${pattern}.test(data.name)}</Text>`)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
|
||||
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/", "/^(a|aa)+$/", "/^(a|a?)+$/", "/^a+a+$/", "/^(\\w+\\s?)*$/"])(
|
||||
"rejects unsafe regex %s in analysis and at runtime",
|
||||
(pattern) => {
|
||||
const template = `<Text>{${pattern}.test(data.name)}</Text>`;
|
||||
expect(validateCustomJsxTemplate(template)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
|
||||
);
|
||||
expect(() =>
|
||||
renderSafeJsx({ template, components, bindings: { data: { name: "a".repeat(10_000) + "!" } } }),
|
||||
).toThrow("UNSAFE_REGEX");
|
||||
},
|
||||
);
|
||||
|
||||
test("allows a single unambiguous variable regex quantifier", () => {
|
||||
const template = '<Text>{/^(ab)+$/.test(data.name) ? "yes" : "no"}</Text>';
|
||||
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
|
||||
expect(() => renderSafeJsx({ template, components, bindings: { data: { name: "abab" } } })).not.toThrow();
|
||||
});
|
||||
|
||||
test("bounds response data depth, node count, and string length without recursive traversal", () => {
|
||||
let deeplyNested: unknown = null;
|
||||
for (let depth = 0; depth <= CUSTOM_JSX_DATA_LIMITS.depth; depth += 1) deeplyNested = { child: deeplyNested };
|
||||
expect(() => createCustomJsxBindings(deeplyNested)).toThrow(/depth limit/u);
|
||||
expect(() => createCustomJsxBindings(Array.from({ length: CUSTOM_JSX_DATA_LIMITS.nodes }).fill(null))).toThrow(
|
||||
/node limit/u,
|
||||
);
|
||||
expect(() => createCustomJsxBindings("x".repeat(CUSTOM_JSX_DATA_LIMITS.stringLength + 1))).toThrow(
|
||||
/string length limit/u,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { customWidgetDefinitionSchema } from "../core";
|
||||
import { CustomWidgetPreviewSessionService } from "../server";
|
||||
import type { PreviewSessionStore } from "../server";
|
||||
|
||||
const definition = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
@@ -109,4 +110,83 @@ describe("preview sessions", () => {
|
||||
),
|
||||
).rejects.toThrow("authentication changed");
|
||||
});
|
||||
|
||||
it("does not delete a preview when a different user looks it up", async () => {
|
||||
const values = new Map<string, unknown>();
|
||||
const store: PreviewSessionStore = {
|
||||
saveSession: async (id, value) => {
|
||||
values.set(id, value);
|
||||
},
|
||||
compareAndSwapSession: async (id, revision, value) => {
|
||||
const current = values.get(id) as { revision?: number } | undefined;
|
||||
if (current?.revision !== revision) return false;
|
||||
values.set(id, value);
|
||||
return true;
|
||||
},
|
||||
getSession: async (id) => values.get(id),
|
||||
deleteSession: async (id) => {
|
||||
values.delete(id);
|
||||
},
|
||||
appendJournal: async () => undefined,
|
||||
getJournal: async () => [],
|
||||
};
|
||||
const service = new CustomWidgetPreviewSessionService({
|
||||
createId: () => "session",
|
||||
encrypt: String,
|
||||
decrypt: String,
|
||||
store,
|
||||
});
|
||||
await service.create({
|
||||
userId: "owner",
|
||||
sources: definition.sources,
|
||||
requests: definition.requests,
|
||||
name: definition.name,
|
||||
template: definition.template,
|
||||
optionDefinitions: definition.options,
|
||||
options: { limit: 10 },
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
await expect(service.get("session", "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(service.get("session", "owner")).resolves.toMatchObject({ id: "session" });
|
||||
});
|
||||
|
||||
it("retries concurrent mutations without losing source, secret, or action state", async () => {
|
||||
const service = new CustomWidgetPreviewSessionService({
|
||||
createId: () => "session",
|
||||
encrypt: (value) => `encrypted:${value}`,
|
||||
decrypt: (value) => value.replace("encrypted:", ""),
|
||||
});
|
||||
await service.create({
|
||||
userId: "owner",
|
||||
sources: {
|
||||
...definition.sources,
|
||||
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "basic" },
|
||||
},
|
||||
requests: definition.requests,
|
||||
name: definition.name,
|
||||
template: definition.template,
|
||||
optionDefinitions: definition.options,
|
||||
options: { limit: 10 },
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
service.setLiveActions("session", "owner", true),
|
||||
service.setSecrets("session", "owner", [{ sourceId: "secondary", kind: "username", value: "operator" }]),
|
||||
service.configureSource(
|
||||
"session",
|
||||
"owner",
|
||||
"default",
|
||||
{ baseUrl: "https://api.example.com", networkScope: "public", auth: "bearer" },
|
||||
[{ sourceId: "default", kind: "apiKey", value: "token" }],
|
||||
),
|
||||
]);
|
||||
|
||||
const session = await service.get("session", "owner");
|
||||
expect(session.liveActions).toBe(true);
|
||||
expect(session.sources.default?.baseUrl).toBe("https://api.example.com");
|
||||
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "token" }]);
|
||||
expect(service.getSecrets(session, "secondary")).toEqual([{ kind: "username", value: "operator" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CustomWidgetRequestLimiter } from "../server";
|
||||
import {
|
||||
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
|
||||
CustomWidgetRequestLimiter,
|
||||
MAX_REQUEST_DURATION_MS,
|
||||
} from "../server";
|
||||
import type { RequestLimitStore } from "../server";
|
||||
|
||||
const input = { category: "action", userId: "user", itemId: "item", definitionId: "definition" } as const;
|
||||
|
||||
describe("custom widget request limits", () => {
|
||||
it("keeps owned concurrency leases beyond the total request deadline", () => {
|
||||
expect(CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS).toBeGreaterThan(MAX_REQUEST_DURATION_MS);
|
||||
});
|
||||
|
||||
it("enforces local per-user/item concurrency and releases capacity", async () => {
|
||||
const limiter = new CustomWidgetRequestLimiter();
|
||||
const releases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire(input)));
|
||||
@@ -39,4 +47,31 @@ describe("custom widget request limits", () => {
|
||||
});
|
||||
expect(store.releaseConcurrency).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases only the concurrency lease owned by the request", async () => {
|
||||
const owners = new Set<string>();
|
||||
const store: RequestLimitStore = {
|
||||
incrementRate: vi.fn(async () => ({ count: 1, retryAfterMs: 60_000 })),
|
||||
acquireConcurrency: vi.fn(async (_key, ownerId) => {
|
||||
owners.add(ownerId);
|
||||
return true;
|
||||
}),
|
||||
releaseConcurrency: vi.fn(async (_key, ownerId) => {
|
||||
owners.delete(ownerId);
|
||||
}),
|
||||
};
|
||||
const releaseFirst = await new CustomWidgetRequestLimiter({ store }).acquire(input);
|
||||
const firstOwner = [...owners][0];
|
||||
expect(store.acquireConcurrency).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
firstOwner,
|
||||
expect.any(Number),
|
||||
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
|
||||
);
|
||||
await releaseFirst();
|
||||
|
||||
expect(firstOwner).toBeTypeOf("string");
|
||||
expect(store.releaseConcurrency).toHaveBeenCalledWith(expect.any(String), firstOwner);
|
||||
expect(owners).not.toContain(firstOwner);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
|
||||
import { createCustomJsxComponents } from "../jsx";
|
||||
import { MAX_REFRESH_INTERVAL_MS, MAX_REFRESH_INTERVAL_SECONDS, normalizeRefreshInterval } from "../runtime/sub-fetch";
|
||||
import type {
|
||||
CustomJsxRequestCapability,
|
||||
CustomWidgetPublishedQueryState,
|
||||
@@ -106,6 +107,31 @@ async function settle() {
|
||||
}
|
||||
|
||||
describe("Custom Widget runtime ports", () => {
|
||||
it("clamps refresh intervals below the browser timer overflow boundary", () => {
|
||||
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS)).toBe(MAX_REFRESH_INTERVAL_MS);
|
||||
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS + 1)).toBe(MAX_REFRESH_INTERVAL_MS);
|
||||
expect(normalizeRefreshInterval(Number.MAX_VALUE)).toBe(MAX_REFRESH_INTERVAL_MS);
|
||||
expect(MAX_REFRESH_INTERVAL_MS).toBeLessThan(2 ** 31 - 1);
|
||||
});
|
||||
|
||||
it("routes binding-construction failures through the runtime error display", async () => {
|
||||
await render(
|
||||
<CustomJsxRenderer
|
||||
template="<Text>Safe</Text>"
|
||||
data={{}}
|
||||
components={{ Text: (() => null) as never }}
|
||||
createBindings={() => {
|
||||
throw new RangeError("Response data exceeded the depth limit");
|
||||
}}
|
||||
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
|
||||
/>,
|
||||
createPort(),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("RUNTIME_RENDER_ERROR");
|
||||
expect(host.textContent).toContain("Response data exceeded the depth limit");
|
||||
});
|
||||
|
||||
it("renders common safe collection and number formatting operations", async () => {
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
@@ -162,9 +188,7 @@ describe("Custom Widget runtime ports", () => {
|
||||
|
||||
await render(
|
||||
<>
|
||||
<button name="shared" role="radio" aria-checked="false" type="button">
|
||||
Outside
|
||||
</button>
|
||||
<input aria-label="Outside" name="shared" type="radio" />
|
||||
<CustomJsxRenderer
|
||||
template={
|
||||
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
|
||||
@@ -179,7 +203,8 @@ describe("Custom Widget runtime ports", () => {
|
||||
);
|
||||
await settle();
|
||||
|
||||
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
|
||||
const outside = host.querySelector<HTMLInputElement>('input[type="radio"]');
|
||||
const [inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
|
||||
expect(outside?.name).toBe("shared");
|
||||
expect(inside?.name).toMatch(/^custom-widget-/u);
|
||||
expect(inside?.name).not.toBe(outside?.name);
|
||||
|
||||
@@ -53,6 +53,27 @@ describe("lean Custom Widget schema", () => {
|
||||
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects source IDs that collide case-insensitively", () => {
|
||||
const result = customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
sources: {
|
||||
Api: { baseUrl: "https://one.example.com", networkScope: "public", auth: "none" },
|
||||
api: { baseUrl: "https://two.example.com", networkScope: "public", auth: "none" },
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: ["sources", "api"],
|
||||
message: expect.stringContaining("conflicts case-insensitively"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invocation params on load and unknown options", () => {
|
||||
const loadParam = customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
|
||||
@@ -7,7 +7,40 @@ import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
|
||||
import { CustomWidgetCodeEditor, PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
|
||||
import type { CustomWidgetEditorMessages } from "../workbench";
|
||||
|
||||
vi.mock("../workbench/direct-code-mirror", () => ({
|
||||
default: ({ id, label, labelledBy }: { id: string; label: string; labelledBy: string }) => (
|
||||
<textarea id={id} aria-label={label} aria-labelledby={labelledBy} />
|
||||
),
|
||||
}));
|
||||
|
||||
const editorMessages: CustomWidgetEditorMessages = {
|
||||
languageJsx: "JSX",
|
||||
languageJson: "JSON",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
components: "Components",
|
||||
componentSearch: "Search components",
|
||||
componentEmpty: "No components",
|
||||
componentCount: (count) => `${count} components`,
|
||||
insertStarter: "Insert starter",
|
||||
format: "Format",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
schema: "Schema",
|
||||
schemaTab: "JSON Schema",
|
||||
minimalTab: "Minimal",
|
||||
fullTab: "Full",
|
||||
errors: (count) => `${count} errors`,
|
||||
warnings: (count) => `${count} warnings`,
|
||||
ready: "Ready",
|
||||
position: ({ line, column }) => `${line}:${column}`,
|
||||
characters: (count) => `${count}`,
|
||||
diagnosticsTitle: "Diagnostics",
|
||||
diagnostic: (diagnostic) => diagnostic.code,
|
||||
};
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
@@ -39,6 +72,25 @@ async function render(node: ReactNode) {
|
||||
}
|
||||
|
||||
describe("Custom Widget workbench UI", () => {
|
||||
test("associates the visible label with the editable CodeMirror control", async () => {
|
||||
await render(
|
||||
<CustomWidgetCodeEditor
|
||||
id="template-editor"
|
||||
label="Template"
|
||||
language="jsx"
|
||||
value="<Text>Hello</Text>"
|
||||
messages={editorMessages}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
const label = host.querySelector('#template-editor-label[for="template-editor"]');
|
||||
expect(label).not.toBeNull();
|
||||
expect(host.querySelector("#template-editor")?.getAttribute("aria-label")).toBe("Template");
|
||||
expect(host.querySelector("#template-editor")?.getAttribute("aria-labelledby")).toBe("template-editor-label");
|
||||
});
|
||||
|
||||
test("renders response data through the Mantine Tree with accessible path actions", async () => {
|
||||
await render(
|
||||
<ResponseTree
|
||||
|
||||
@@ -100,7 +100,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
description={props.description}
|
||||
error={props.error}
|
||||
required={props.required}
|
||||
labelProps={{ htmlFor: props.id }}
|
||||
labelProps={{ id: `${props.id}-label`, htmlFor: props.id }}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.root}>
|
||||
@@ -152,7 +152,8 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
{editorReady ? (
|
||||
<Suspense fallback={<EditorLoader />}>
|
||||
<LazyCodeMirror
|
||||
id={`${props.id}-root`}
|
||||
id={props.id}
|
||||
labelledBy={`${props.id}-label`}
|
||||
value={props.value}
|
||||
language={props.language}
|
||||
diagnostics={diagnostics}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface DirectCodeMirrorProps {
|
||||
completions: Completion[];
|
||||
id: string;
|
||||
label: string;
|
||||
labelledBy: string;
|
||||
placeholder?: string;
|
||||
height: string;
|
||||
theme: "light" | "dark";
|
||||
@@ -121,7 +122,7 @@ function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
|
||||
};
|
||||
}),
|
||||
),
|
||||
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }),
|
||||
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label, "aria-labelledby": props.labelledBy }),
|
||||
EditorView.editable.of(!props.readOnly),
|
||||
EditorState.readOnly.of(props.readOnly),
|
||||
keymap.of(props.readOnly ? [] : [indentWithTab]),
|
||||
|
||||
Reference in new issue
Block a user