fix: keep custom widget limits client safe

This commit is contained in:
ajnart committed 2026-08-20 14:59:18 +02:00
1 parent ca20431d84
commit 17aef0588f
9 files changed
+27 -23

No files matched your search

@@ -2,13 +2,13 @@ import { fetchApi } from "@homarr/api/client";
import { mapWithConcurrency } from "@homarr/common";
import {
customWidgetDefinitionSchema,
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
getCustomWidgetDefaultOptions,
getCustomWidgetRequiredSecretKinds,
parseCustomWidgetAiResponse,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/server";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { UseFormReturnType } from "@mantine/form";
@@ -14,7 +14,7 @@ import {
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/server";
import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
@@ -2,6 +2,7 @@ export * from "./bundled-widgets";
export * from "./examples";
export * from "./import";
export * from "./options";
export * from "./request-limits";
export * from "./schema";
export * from "./schema-references";
export * from "./secret-requirements";
@@ -0,0 +1 @@
export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4;
@@ -19,7 +19,6 @@ export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpR
export {
CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
CustomWidgetRequestLimiter,
} from "./request-limits";
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
@@ -13,7 +13,8 @@ import {
validateCustomWidgetUrl,
} from "./network-policy";
import { closeDispatcher } from "./request-dispatcher-lifecycle";
import { parseResponseBody } from "./response";
import { isCustomWidgetRequestTimeoutError } from "./request-timeout";
import { normalizeResponseHeaders, parseResponseBody } from "./response";
export {
assertSafeStaticHeaders,
@@ -28,6 +29,7 @@ export {
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
} from "./response";
export { isCustomWidgetRequestTimeoutError } from "./request-timeout";
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
@@ -35,7 +37,6 @@ const MAX_QUERY_REDIRECTS = 3;
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
const MAX_RESPONSE_CACHE_BYTES = 64 * 1024 * 1024;
export const MAX_REQUEST_DURATION_MS = 45_000;
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE";
export interface CustomWidgetAuthConfig {
@@ -219,23 +220,6 @@ async function performRequestWithinDeadline(
}
}
export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean {
if (signals.some((signal) => signal.aborted)) return true;
if (!(error instanceof Error)) return false;
if (error.name.includes("Timeout")) return true;
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
return code !== undefined && TIMEOUT_ERROR_CODES.has(code);
}
function normalizeResponseHeaders(values: Record<string, string | string[] | undefined>) {
const headers = new Headers();
for (const [name, value] of Object.entries(values)) {
if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry));
else if (value !== undefined) headers.set(name, value);
}
return headers;
}
function assertRequest(input: CustomWidgetHttpRequest): void {
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
@@ -1,5 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "../core/request-limits";
import { CustomWidgetDomainError } from "./errors";
const WINDOW_MS = 60_000;
@@ -7,7 +8,6 @@ const WINDOW_MS = 60_000;
// 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;
export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4;
export const CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT = 8;
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const;
const DEFINITION_CATEGORY_LIMITS = { query: 240, action: 40, delete: 12 } as const;
@@ -0,0 +1,9 @@
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean {
if (signals.some((signal) => signal.aborted)) return true;
if (!(error instanceof Error)) return false;
if (error.name.includes("Timeout")) return true;
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
return code !== undefined && TIMEOUT_ERROR_CODES.has(code);
}
@@ -1,3 +1,4 @@
import { Headers } from "undici";
import type { Response } from "undici";
import { CustomWidgetDomainError } from "./errors";
@@ -5,6 +6,15 @@ export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
export const MAX_RESPONSE_JSON_DEPTH = 32;
export const MAX_RESPONSE_JSON_NODES = 50_000;
export function normalizeResponseHeaders(values: Record<string, string | string[] | undefined>) {
const headers = new Headers();
for (const [name, value] of Object.entries(values)) {
if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry));
else if (value !== undefined) headers.set(name, value);
}
return headers;
}
export function assertJsonBudget(value: unknown): void {
let nodes = 0;
const visit = (current: unknown, depth: number): void => {