fix: keep custom widget limits client safe
This commit is contained in:
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 => {
|
||||
|
||||
Reference in new issue
Block a user