feat(custom-widgets): add secure v2 authoring
This commit is contained in:
56 files changed
+12613
-1726
No files matched your search
@@ -13,6 +13,7 @@ import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
downloads: downloadsRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
export const AUTH_HANDLERS: Record<string, (headers: Headers, url: URL, apiKey: string, headerName?: string) => void> =
|
||||
{
|
||||
bearer: (headers, _url, apiKey) => {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
},
|
||||
apiKeyHeader: (headers, _url, apiKey, headerName) => {
|
||||
headers.set(headerName ?? "X-API-Key", apiKey);
|
||||
},
|
||||
apiKeyQuery: (_headers, url, apiKey, headerName) => {
|
||||
url.searchParams.set(headerName ?? "api_key", apiKey);
|
||||
},
|
||||
};
|
||||
type HeaderSetter = Pick<Headers, "set">;
|
||||
|
||||
export const AUTH_HANDLERS: Record<
|
||||
string,
|
||||
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
|
||||
> = {
|
||||
bearer: (headers, _url, apiKey) => {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
},
|
||||
apiKeyHeader: (headers, _url, apiKey, headerName) => {
|
||||
headers.set(headerName ?? "X-API-Key", apiKey);
|
||||
},
|
||||
apiKeyQuery: (_headers, url, apiKey, headerName) => {
|
||||
url.searchParams.set(headerName ?? "api_key", apiKey);
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -20,7 +24,7 @@ export function buildSecretMap(secrets: Array<{ kind: string; value: string }>):
|
||||
}
|
||||
|
||||
export function applyAuth(
|
||||
headers: Headers,
|
||||
headers: HeaderSetter,
|
||||
url: URL,
|
||||
authType: string,
|
||||
secrets: Array<{ kind: string; value: string }>,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -122,6 +122,16 @@ const extractors: Record<string, Extractor> = {
|
||||
type: "customJsx" as const,
|
||||
template: config.template as string,
|
||||
data: json,
|
||||
jsxApiVersion: config.jsxApiVersion === 2 ? 2 : 1,
|
||||
requestCapabilities:
|
||||
config.jsxApiVersion === 2 && Array.isArray(config.requests)
|
||||
? (config.requests as Array<Record<string, unknown>>).map((request) => ({
|
||||
id: request.id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
minimumBoardPermission: request.minimumBoardPermission,
|
||||
}))
|
||||
: [],
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import {
|
||||
customJsxNetworkScopes,
|
||||
customJsxRequestSchema,
|
||||
customWidgetAuthTypes,
|
||||
customWidgetSecretKinds,
|
||||
} from "@homarr/validation/custom-widget";
|
||||
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "@homarr/validation/custom-widget";
|
||||
|
||||
const PREVIEW_SESSION_TTL_MS = 5 * 60_000;
|
||||
const PREVIEW_SESSION_PREFIX = "custom-widget:preview-session:";
|
||||
const PREVIEW_JOURNAL_PREFIX = "custom-widget:preview-journal:";
|
||||
const MAX_PREVIEW_JOURNAL_ENTRIES = 50;
|
||||
|
||||
const storedPreviewSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.number(),
|
||||
baseUrl: z.string(),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().nullable(),
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
|
||||
networkScope: z.enum(customJsxNetworkScopes),
|
||||
requests: z.array(customJsxRequestSchema),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof storedPreviewSessionSchema>;
|
||||
|
||||
const previewJournalEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
requestId: z.string(),
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
pathTemplate: z.string(),
|
||||
status: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
simulated: z.boolean(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type CustomWidgetPreviewJournalEntry = z.infer<typeof previewJournalEntrySchema>;
|
||||
|
||||
export interface CreatePreviewSessionInput {
|
||||
userId: string;
|
||||
baseUrl: string;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
secrets: Array<{ kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
requests: CustomJsxRequest[];
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const localSessions = new Map<string, CustomWidgetPreviewSession>();
|
||||
const localJournals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
|
||||
|
||||
const getRedisClient = () => {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
|
||||
redisClient ??= createRedisClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
const sessionKey = (id: string) => `${PREVIEW_SESSION_PREFIX}${id}`;
|
||||
const journalKey = (id: string) => `${PREVIEW_JOURNAL_PREFIX}${id}`;
|
||||
|
||||
const saveSession = async (session: CustomWidgetPreviewSession) => {
|
||||
const ttlMs = Math.max(0, session.expiresAt - Date.now());
|
||||
if (ttlMs === 0) throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
localSessions.set(session.id, session);
|
||||
return;
|
||||
}
|
||||
await redis.set(sessionKey(session.id), JSON.stringify(session), "PX", ttlMs);
|
||||
};
|
||||
|
||||
export const createPreviewSession = async (input: CreatePreviewSessionInput) => {
|
||||
const session: CustomWidgetPreviewSession = {
|
||||
id: createId(),
|
||||
userId: input.userId,
|
||||
expiresAt: Date.now() + PREVIEW_SESSION_TTL_MS,
|
||||
baseUrl: input.baseUrl,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName ?? null,
|
||||
secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: encryptSecret(secret.value) })),
|
||||
networkScope: input.networkScope,
|
||||
requests: input.requests,
|
||||
definitionId: input.definitionId,
|
||||
liveActions: false,
|
||||
};
|
||||
await saveSession(session);
|
||||
return { id: session.id, expiresAt: session.expiresAt, liveActions: false as const };
|
||||
};
|
||||
|
||||
export const getPreviewSession = async (id: string, userId: string): Promise<CustomWidgetPreviewSession> => {
|
||||
const redis = getRedisClient();
|
||||
const raw = redis ? await redis.get(sessionKey(id)) : localSessions.get(id);
|
||||
let candidate: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
candidate = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
candidate = null;
|
||||
}
|
||||
}
|
||||
const parsed = storedPreviewSessionSchema.safeParse(candidate);
|
||||
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= Date.now()) {
|
||||
if (!redis) localSessions.delete(id);
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export const setPreviewSessionLiveActions = async (id: string, userId: string, enabled: boolean) => {
|
||||
const session = await getPreviewSession(id, userId);
|
||||
const updated = { ...session, liveActions: enabled };
|
||||
await saveSession(updated);
|
||||
return { id, expiresAt: updated.expiresAt, liveActions: enabled };
|
||||
};
|
||||
|
||||
export const appendPreviewJournal = async (
|
||||
session: CustomWidgetPreviewSession,
|
||||
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
||||
) => {
|
||||
const value = previewJournalEntrySchema.parse({ ...entry, id: createId(), timestamp: Date.now() });
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
localJournals.set(
|
||||
session.id,
|
||||
[value, ...(localJournals.get(session.id) ?? [])].slice(0, MAX_PREVIEW_JOURNAL_ENTRIES),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ttlMs = Math.max(1, session.expiresAt - Date.now());
|
||||
await redis
|
||||
.multi()
|
||||
.lpush(journalKey(session.id), JSON.stringify(value))
|
||||
.ltrim(journalKey(session.id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
|
||||
.pexpire(journalKey(session.id), ttlMs)
|
||||
.exec();
|
||||
};
|
||||
|
||||
export const getPreviewJournal = async (id: string, userId: string) => {
|
||||
await getPreviewSession(id, userId);
|
||||
const redis = getRedisClient();
|
||||
const entries = redis
|
||||
? await redis.lrange(journalKey(id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
|
||||
: (localJournals.get(id) ?? []);
|
||||
return entries.flatMap((entry) => {
|
||||
let candidate: unknown = entry;
|
||||
if (typeof entry === "string") {
|
||||
try {
|
||||
candidate = JSON.parse(entry) as unknown;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const parsed = previewJournalEntrySchema.safeParse(candidate);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
});
|
||||
};
|
||||
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) =>
|
||||
session.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: decryptSecret(secret.value as `${string}.${string}`),
|
||||
}));
|
||||
@@ -0,0 +1,429 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Agent, fetch, Headers } from "undici";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import type { Response } from "undici";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { applyAuth } from "./auth";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:http" });
|
||||
|
||||
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
||||
export const MAX_RESPONSE_JSON_DEPTH = 32;
|
||||
export const MAX_RESPONSE_JSON_NODES = 50_000;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
|
||||
const RESERVED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
]);
|
||||
|
||||
const alwaysBlockedAddresses = new BlockList();
|
||||
alwaysBlockedAddresses.addSubnet("0.0.0.0", 8, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("100.64.0.0", 10, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("169.254.0.0", 16, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.0.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("192.0.2.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.18.0.0", 15, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("198.51.100.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("203.0.113.0", 24, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("224.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addSubnet("240.0.0.0", 4, "ipv4");
|
||||
alwaysBlockedAddresses.addAddress("::", "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("64:ff9b::", 96, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("100::", 64, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("2001:db8::", 32, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("fe80::", 10, "ipv6");
|
||||
alwaysBlockedAddresses.addSubnet("ff00::", 8, "ipv6");
|
||||
alwaysBlockedAddresses.addAddress("fd00:ec2::254", "ipv6");
|
||||
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
|
||||
const loopbackAddresses = new BlockList();
|
||||
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
loopbackAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
type AddressFamily = 4 | 6;
|
||||
type ResolvedAddress = { address: string; family: AddressFamily };
|
||||
type AddressClass = "public" | "private" | "loopback" | "blocked";
|
||||
|
||||
export interface CustomWidgetAuthConfig {
|
||||
type: string;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
headerName?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
baseUrl: string;
|
||||
targetUrl?: string | URL;
|
||||
method: CustomWidgetMethod;
|
||||
body?: string;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const responseCache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||
const inFlightRequests = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
|
||||
|
||||
const pruneResponseCache = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of responseCache) {
|
||||
if (entry.expiresAt <= now) responseCache.delete(key);
|
||||
}
|
||||
while (responseCache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
|
||||
const oldestKey = responseCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
responseCache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHostname = (hostname: string) =>
|
||||
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
|
||||
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
|
||||
|
||||
export const classifyAddress = (address: string): AddressClass => {
|
||||
const normalized = normalizeHostname(address).toLowerCase();
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
if (family === 0) return "blocked";
|
||||
|
||||
if (normalized.includes("::ffff:")) return "blocked";
|
||||
if (alwaysBlockedAddresses.check(normalized, familyName(family))) return "blocked";
|
||||
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
|
||||
if (privateAddresses.check(normalized, familyName(family))) return "private";
|
||||
return "public";
|
||||
};
|
||||
|
||||
const isAddressAllowed = (classification: AddressClass, scope: CustomJsxNetworkScope) => {
|
||||
if (classification === "blocked") return false;
|
||||
if (classification === "public") return true;
|
||||
if (classification === "private") return scope === "private" || scope === "loopback";
|
||||
return scope === "loopback";
|
||||
};
|
||||
|
||||
export const resolveAndValidateHost = async (
|
||||
hostname: string,
|
||||
scope: CustomJsxNetworkScope,
|
||||
): Promise<ResolvedAddress[]> => {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const literalFamily = isIP(normalized) as AddressFamily | 0;
|
||||
const addresses = literalFamily
|
||||
? [{ address: normalized, family: literalFamily }]
|
||||
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
|
||||
}
|
||||
|
||||
for (const resolved of addresses) {
|
||||
const classification = classifyAddress(resolved.address);
|
||||
if (!isAddressAllowed(classification, scope)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Target address is not allowed by the ${scope} network scope`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
};
|
||||
|
||||
export const validateCustomWidgetUrl = (value: string | URL): URL => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = value instanceof URL ? new URL(value) : new URL(value);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
|
||||
}
|
||||
if (url.hash) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const resolveSameOriginTarget = (baseUrlValue: string, targetUrlValue?: string | URL): URL => {
|
||||
const baseUrl = validateCustomWidgetUrl(baseUrlValue);
|
||||
const targetUrl = validateCustomWidgetUrl(targetUrlValue ?? baseUrl);
|
||||
if (targetUrl.origin !== baseUrl.origin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Named requests must stay on the widget's origin" });
|
||||
}
|
||||
return targetUrl;
|
||||
};
|
||||
|
||||
export const assertSafeStaticHeaders = (headers: Record<string, string> | undefined): void => {
|
||||
for (const name of Object.keys(headers ?? {})) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (
|
||||
RESERVED_HEADERS.has(normalized) ||
|
||||
normalized.startsWith("proxy-") ||
|
||||
normalized.startsWith("sec-") ||
|
||||
normalized.startsWith("x-forwarded-")
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createPinnedAgent = (addresses: ResolvedAddress[]) => {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = requestedFamily ? addresses.filter((address) => address.family === requestedFamily) : addresses;
|
||||
const selected = candidates[0];
|
||||
if (!selected) {
|
||||
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
return;
|
||||
}
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
|
||||
return new Agent({
|
||||
connect: { lookup: customLookup },
|
||||
connectTimeout: REQUEST_TIMEOUT_MS,
|
||||
headersTimeout: REQUEST_TIMEOUT_MS,
|
||||
bodyTimeout: REQUEST_TIMEOUT_MS,
|
||||
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
||||
});
|
||||
};
|
||||
|
||||
const readLimitedBody = async (response: Response): Promise<string> => {
|
||||
const contentLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
totalBytes += chunk.value.byteLength;
|
||||
if (totalBytes > MAX_RESPONSE_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
};
|
||||
|
||||
export const assertJsonBudget = (value: unknown): void => {
|
||||
let nodes = 0;
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
nodes += 1;
|
||||
if (nodes > MAX_RESPONSE_JSON_NODES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
|
||||
}
|
||||
if (depth > MAX_RESPONSE_JSON_DEPTH) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (const entry of current) visit(entry, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (current !== null && typeof current === "object") {
|
||||
for (const entry of Object.values(current)) visit(entry, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, 0);
|
||||
};
|
||||
|
||||
const parseResponseBody = async (response: Response): Promise<unknown> => {
|
||||
const text = await readLimitedBody(response);
|
||||
if (text === "") return null;
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.includes("json")) {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text) as unknown;
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const performRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
if (input.kind === "query" && input.method !== "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Queries must use GET" });
|
||||
}
|
||||
if (input.kind === "action" && input.method === "GET") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
|
||||
}
|
||||
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES) {
|
||||
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
|
||||
}
|
||||
assertSafeStaticHeaders(input.staticHeaders);
|
||||
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
const initialOrigin = baseUrl.origin;
|
||||
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
||||
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
const addresses = await resolveAndValidateHost(currentUrl.hostname, input.networkScope);
|
||||
const dispatcher = createPinnedAgent(addresses);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
|
||||
if (input.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (input.auth) {
|
||||
if (input.auth.type === "apiKeyHeader") {
|
||||
assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||
}
|
||||
applyAuth(headers, currentUrl, input.auth.type, input.auth.secrets, input.auth.headerName);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(currentUrl, {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method === "GET" ? undefined : input.body,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget request failed", {
|
||||
origin: currentUrl.origin,
|
||||
method: input.method,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: error instanceof Error ? error.message : "External request failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const isRedirect = [301, 302, 303, 307, 308].includes(response.status);
|
||||
if (!isRedirect) {
|
||||
try {
|
||||
const data = await parseResponseBody(response);
|
||||
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
await response.body?.cancel();
|
||||
await dispatcher.close();
|
||||
if (redirectCount >= maxRedirects) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
|
||||
}
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
|
||||
}
|
||||
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
|
||||
if (redirected.origin !== initialOrigin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
|
||||
}
|
||||
currentUrl = redirected;
|
||||
}
|
||||
};
|
||||
|
||||
export const executeCustomWidgetRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
|
||||
const cacheKey = input.method === "GET" ? input.cacheKey : undefined;
|
||||
if (!cacheKey) return performRequest(input);
|
||||
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.response;
|
||||
if (cached) responseCache.delete(cacheKey);
|
||||
|
||||
const inFlight = inFlightRequests.get(cacheKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = performRequest(input)
|
||||
.then((response) => {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
|
||||
pruneResponseCache();
|
||||
responseCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000,
|
||||
response,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.finally(() => inFlightRequests.delete(cacheKey));
|
||||
inFlightRequests.set(cacheKey, promise);
|
||||
return promise;
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:limits" });
|
||||
const WINDOW_MS = 60_000;
|
||||
const CONCURRENCY_TTL_MS = 30_000;
|
||||
|
||||
const categoryLimits = {
|
||||
query: 60,
|
||||
action: 10,
|
||||
delete: 3,
|
||||
} as const;
|
||||
|
||||
type RequestCategory = keyof typeof categoryLimits;
|
||||
|
||||
export interface RequestLimitInput {
|
||||
category: RequestCategory;
|
||||
userId?: string;
|
||||
itemId: string;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
class RequestLimitError extends FlattenError {
|
||||
constructor(message: string, retryAfterMs: number) {
|
||||
super(message, { retryAfterMs });
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null | undefined;
|
||||
const getRedisClient = () => {
|
||||
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
|
||||
redisClient ??= createRedisClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
|
||||
|
||||
const rateScript = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
return { count, redis.call('PTTL', KEYS[1]) }
|
||||
`;
|
||||
|
||||
const concurrencyScript = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
||||
if count > tonumber(ARGV[1]) then
|
||||
redis.call('DECR', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
return count
|
||||
`;
|
||||
|
||||
const releaseConcurrencyScript = `
|
||||
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if count <= 1 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 0
|
||||
end
|
||||
return redis.call('DECR', KEYS[1])
|
||||
`;
|
||||
|
||||
const localRateBuckets = new Map<string, number[]>();
|
||||
const localConcurrency = new Map<string, number>();
|
||||
|
||||
const throwLimitExceeded = (retryAfterMs: number): never => {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Custom widget request limit exceeded",
|
||||
cause: new RequestLimitError("Custom widget request limit exceeded", Math.max(1, retryAfterMs)),
|
||||
});
|
||||
};
|
||||
|
||||
const enforceLocalRateLimit = (key: string, limit: number) => {
|
||||
const now = Date.now();
|
||||
const recent = (localRateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
|
||||
if (recent.length >= limit) {
|
||||
throwLimitExceeded(WINDOW_MS - (now - (recent[0] ?? now)));
|
||||
}
|
||||
recent.push(now);
|
||||
localRateBuckets.set(key, recent);
|
||||
};
|
||||
|
||||
const acquireLocalConcurrency = (key: string, limit: number) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current >= limit) throwLimitExceeded(1_000);
|
||||
localConcurrency.set(key, current + 1);
|
||||
};
|
||||
|
||||
const releaseLocalConcurrency = (key: string) => {
|
||||
const current = localConcurrency.get(key) ?? 0;
|
||||
if (current <= 1) localConcurrency.delete(key);
|
||||
else localConcurrency.set(key, current - 1);
|
||||
};
|
||||
|
||||
const enforceRedisRateLimit = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const result = (await redis.eval(rateScript, 1, key, WINDOW_MS)) as [number, number];
|
||||
const [count, ttl] = result.map(Number) as [number, number];
|
||||
if (count > limit) throwLimitExceeded(ttl > 0 ? ttl : WINDOW_MS);
|
||||
};
|
||||
|
||||
const acquireRedisConcurrency = async (redis: RedisClient, key: string, limit: number) => {
|
||||
const count = Number(await redis.eval(concurrencyScript, 1, key, limit, CONCURRENCY_TTL_MS));
|
||||
if (count === 0) throwLimitExceeded(1_000);
|
||||
};
|
||||
|
||||
const releaseRedisConcurrency = async (redis: RedisClient, key: string) => {
|
||||
await redis.eval(releaseConcurrencyScript, 1, key);
|
||||
};
|
||||
|
||||
export const acquireCustomWidgetRequestLimit = async (input: RequestLimitInput): Promise<() => Promise<void>> => {
|
||||
const identity = input.userId ?? "anonymous";
|
||||
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
|
||||
const userConcurrencyKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionConcurrencyKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
|
||||
const redis = getRedisClient();
|
||||
|
||||
if (!redis) {
|
||||
enforceLocalRateLimit(rateKey, categoryLimits[input.category]);
|
||||
acquireLocalConcurrency(userConcurrencyKey, 4);
|
||||
try {
|
||||
acquireLocalConcurrency(definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
releaseLocalConcurrency(userConcurrencyKey);
|
||||
releaseLocalConcurrency(definitionConcurrencyKey);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await enforceRedisRateLimit(redis, rateKey, categoryLimits[input.category]);
|
||||
await acquireRedisConcurrency(redis, userConcurrencyKey, 4);
|
||||
try {
|
||||
await acquireRedisConcurrency(redis, definitionConcurrencyKey, 8);
|
||||
} catch (error) {
|
||||
await releaseRedisConcurrency(redis, userConcurrencyKey);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Custom widget rate limiter is unavailable", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Request limiter is unavailable" });
|
||||
}
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
releaseRedisConcurrency(redis, userConcurrencyKey),
|
||||
releaseRedisConcurrency(redis, definitionConcurrencyKey),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error("Failed to release custom widget request capacity", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
|
||||
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
|
||||
|
||||
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
const declaredNames = Object.keys(request.parameters).toSorted();
|
||||
const suppliedNames = Object.keys(params).toSorted();
|
||||
if (
|
||||
declaredNames.length !== suppliedNames.length ||
|
||||
declaredNames.some((name, index) => name !== suppliedNames[index])
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
|
||||
}
|
||||
for (const [name, expectedType] of Object.entries(request.parameters)) {
|
||||
if (typeof params[name] !== expectedType) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Request parameter '${name}' must be ${expectedType}` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const renderRequestBody = (
|
||||
template: CustomJsxRequest["bodyTemplate"],
|
||||
params: CustomJsxRuntimeParams,
|
||||
): string | undefined => {
|
||||
if (template === undefined) return undefined;
|
||||
const substitute = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(substitute);
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
const parameter = params[entries[0][1]];
|
||||
if (parameter === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown body parameter '${entries[0][1]}'` });
|
||||
}
|
||||
return parameter;
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(substitute(template));
|
||||
};
|
||||
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
validateRuntimeParams(request, params);
|
||||
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
|
||||
const value = params[name];
|
||||
if (value === undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
|
||||
}
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
if (/[{}]/.test(path)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Path template contains an invalid placeholder" });
|
||||
}
|
||||
return resolveSameOriginTarget(baseUrl, new URL(path, baseUrl));
|
||||
};
|
||||
|
||||
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
|
||||
const stableParams = Object.fromEntries(
|
||||
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
createPreviewSession,
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
getPreviewSessionSecrets,
|
||||
setPreviewSessionLiveActions,
|
||||
} from "../../custom-widget/preview-sessions";
|
||||
|
||||
const queryRequest = {
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{id}",
|
||||
parameters: { id: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
} as const;
|
||||
|
||||
describe("custom widget preview sessions", () => {
|
||||
test("stores encrypted credentials in a short-lived user-bound manifest", async () => {
|
||||
const created = await createPreviewSession({
|
||||
userId: "admin-1",
|
||||
baseUrl: "https://example.com/api",
|
||||
authType: "bearer",
|
||||
secrets: [{ kind: "apiKey", value: "plain-secret" }],
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
});
|
||||
|
||||
const session = await getPreviewSession(created.id, "admin-1");
|
||||
|
||||
expect(session.secrets[0]?.value).not.toContain("plain-secret");
|
||||
expect(getPreviewSessionSecrets(session)).toEqual([{ kind: "apiKey", value: "plain-secret" }]);
|
||||
await expect(getPreviewSession(created.id, "admin-2")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
test("keeps actions simulated until explicitly enabled", async () => {
|
||||
const created = await createPreviewSession({
|
||||
userId: "admin-1",
|
||||
baseUrl: "https://example.com/api",
|
||||
authType: "none",
|
||||
secrets: [],
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
});
|
||||
|
||||
expect((await getPreviewSession(created.id, "admin-1")).liveActions).toBe(false);
|
||||
await setPreviewSessionLiveActions(created.id, "admin-1", true);
|
||||
const session = await getPreviewSession(created.id, "admin-1");
|
||||
expect(session.liveActions).toBe(true);
|
||||
await appendPreviewJournal(session, {
|
||||
requestId: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{id}",
|
||||
status: 200,
|
||||
durationMs: 12,
|
||||
simulated: false,
|
||||
});
|
||||
expect(await getPreviewJournal(created.id, "admin-1")).toEqual([
|
||||
expect.objectContaining({ requestId: "status", pathTemplate: "/status/{id}", durationMs: 12 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
assertJsonBudget,
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "../../custom-widget/request-executor";
|
||||
|
||||
describe("custom widget request executor", () => {
|
||||
test.each([
|
||||
["8.8.8.8", "public"],
|
||||
["10.0.0.1", "private"],
|
||||
["127.0.0.1", "loopback"],
|
||||
["169.254.169.254", "blocked"],
|
||||
["224.0.0.1", "blocked"],
|
||||
["::ffff:127.0.0.1", "blocked"],
|
||||
["fe80::1", "blocked"],
|
||||
] as const)("classifies %s as %s", (address, expected) => {
|
||||
expect(classifyAddress(address)).toBe(expected);
|
||||
});
|
||||
|
||||
test("enforces configured address scopes", async () => {
|
||||
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
|
||||
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
|
||||
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows only credential-free HTTP(S) URLs without fragments", () => {
|
||||
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
|
||||
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("Only HTTP and HTTPS");
|
||||
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
|
||||
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
|
||||
});
|
||||
|
||||
test("rejects cross-origin named request targets", () => {
|
||||
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
|
||||
expect(() => resolveSameOriginTarget("https://example.com/base", "https://attacker.example/status")).toThrow(
|
||||
"origin",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["Authorization", "Cookie", "Host", "Proxy-Authorization", "Sec-Fetch-Site", "X-Forwarded-For"])(
|
||||
"rejects the reserved %s header",
|
||||
(header) => {
|
||||
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects response JSON that exceeds the depth budget", () => {
|
||||
let nested: Record<string, unknown> = {};
|
||||
const root = nested;
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
nested.child = {};
|
||||
nested = nested.child as Record<string, unknown>;
|
||||
}
|
||||
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import { acquireCustomWidgetRequestLimit } from "../../custom-widget/request-limits";
|
||||
|
||||
test("enforces the server-side DELETE rate limit with retry metadata", async () => {
|
||||
const input = { category: "delete" as const, userId: createId(), itemId: createId(), definitionId: createId() };
|
||||
for (let count = 0; count < 3; count += 1) {
|
||||
const release = await acquireCustomWidgetRequestLimit(input);
|
||||
await release();
|
||||
}
|
||||
|
||||
try {
|
||||
await acquireCustomWidgetRequestLimit(input);
|
||||
expect.unreachable("the fourth DELETE request should be rate limited");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
||||
const cause = (error as { cause?: { flatten?: () => Record<string, unknown> } }).cause;
|
||||
expect(cause?.flatten?.()).toMatchObject({ retryAfterMs: expect.any(Number) });
|
||||
}
|
||||
});
|
||||
|
||||
test("enforces four concurrent requests per user and item", async () => {
|
||||
const input = { category: "query" as const, userId: createId(), itemId: createId(), definitionId: createId() };
|
||||
const releases = await Promise.all(Array.from({ length: 4 }, () => acquireCustomWidgetRequestLimit(input)));
|
||||
|
||||
await expect(acquireCustomWidgetRequestLimit(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
||||
await Promise.all(releases.map((release) => release()));
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-executor", () => ({
|
||||
executeCustomWidgetRequest: mocks.executeRequest,
|
||||
resolveSameOriginTarget: (_baseUrl: string, targetUrl: string | URL) => new URL(targetUrl),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { customApiRouter } from "../../widgets/custom-api";
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
const queryRequest = {
|
||||
id: "status",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/status/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
} as const;
|
||||
|
||||
const actionRequest = {
|
||||
id: "toggle",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/toggle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
} as const;
|
||||
|
||||
const deleteRequest = {
|
||||
id: "delete",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/resource",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "full",
|
||||
} as const;
|
||||
|
||||
const setup = async ({
|
||||
isPublic = true,
|
||||
method = "GET",
|
||||
requests = [queryRequest, actionRequest, deleteRequest],
|
||||
}: {
|
||||
isPublic?: boolean;
|
||||
method?: "GET" | "POST";
|
||||
requests?: readonly Record<string, unknown>[];
|
||||
} = {}) => {
|
||||
const db = createDb();
|
||||
const ownerId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
const definitionId = createId();
|
||||
await db.insert(users).values({ id: ownerId });
|
||||
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: definitionId,
|
||||
name: "Custom JSX",
|
||||
url: "https://example.com/api",
|
||||
method,
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text>{data.value}</Text>",
|
||||
networkScope: "public",
|
||||
requests,
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringifySuperJson({ definitionId, refreshInterval: 30 }),
|
||||
});
|
||||
return { db, ownerId, boardId, itemId, definitionId };
|
||||
};
|
||||
|
||||
describe("customApiRouter v2", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("does not expose the removed raw subFetch procedure", () => {
|
||||
expect(Object.hasOwn(customApiRouter._def.procedures, "subFetch")).toBe(false);
|
||||
});
|
||||
|
||||
test("allows anonymous queries only through a public board placement", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
const result = await caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: 200, data: { value: 42 } });
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("does not expose a placed definition through an inaccessible board", async () => {
|
||||
const { db, itemId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not execute non-GET base data requests automatically", async () => {
|
||||
const { db, itemId } = await setup({ method: "POST" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).resolves.toEqual({ type: "networkAccessNeedsReview" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot select a request from a definition that is not attached to the item", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ requests: [] });
|
||||
const otherDefinitionId = createId();
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: otherDefinitionId,
|
||||
name: "Other",
|
||||
url: "https://example.com/api",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text />",
|
||||
networkScope: "public",
|
||||
requests: [queryRequest],
|
||||
}),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(
|
||||
caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects mismatched request kinds and typed parameters", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: 42 } })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects disabled definitions before any network request", async () => {
|
||||
const { db, itemId, definitionId } = await setup();
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, definitionId));
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires modify permission for named actions", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const viewerId = createId();
|
||||
await db.insert(users).values({ id: viewerId });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(viewerId) });
|
||||
|
||||
await expect(
|
||||
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("executes an authorized named action once with typed body substitution", async () => {
|
||||
const { db, itemId, boardId } = await setup({ isPublic: false });
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
|
||||
const result = await caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mocks.executeRequest).toHaveBeenCalledOnce();
|
||||
expect(mocks.executeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires explicit confirmation before DELETE", async () => {
|
||||
const { db, itemId, ownerId } = await setup({ isPublic: false });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(caller.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,213 +1,271 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
import { applyAuth } from "../custom-widget/auth";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { customJsxDisplayConfigV2Schema, displayConfigSchema } from "@homarr/validation/custom-widget";
|
||||
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
|
||||
import { executeCustomWidgetRequest } from "../custom-widget/request-executor";
|
||||
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
|
||||
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
|
||||
|
||||
const logger = createLogger({ module: "widget:customApi" });
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_BODY_SIZE = 10_000;
|
||||
const runtimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
||||
const itemInputSchema = z.object({ itemId: z.string().min(1) });
|
||||
const namedRequestInputSchema = itemInputSchema.extend({
|
||||
requestId: z.string().min(1).max(64),
|
||||
params: runtimeParamsSchema.default({}),
|
||||
});
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
|
||||
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
|
||||
|
||||
const validateUrl = (urlString: string): URL => new URL(urlString);
|
||||
const parseItemOptions = (raw: string): { definitionId: string } => {
|
||||
try {
|
||||
const options = parseSuperJson(raw) as Record<string, unknown>;
|
||||
if (typeof options.definitionId !== "string" || options.definitionId.length === 0) throw new Error();
|
||||
return { definitionId: options.definitionId };
|
||||
} catch {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTargetUrl = (definitionUrl: string, targetUrl: string): URL =>
|
||||
new URL(targetUrl, validateUrl(definitionUrl));
|
||||
|
||||
const assertSameOrigin = (definitionUrl: string, targetUrl: URL): void => {
|
||||
const definitionOrigin = validateUrl(definitionUrl).origin;
|
||||
if (targetUrl.origin !== definitionOrigin) {
|
||||
const parseDisplayConfig = (raw: string): DisplayConfig => {
|
||||
try {
|
||||
return displayConfigSchema.parse(parseSuperJson(raw));
|
||||
} catch (error) {
|
||||
logger.error("Invalid custom widget display configuration", { error });
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "URL must be on the same domain as the widget definition",
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "Custom widget network access needs review",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const parseResponseData = async (response: Response): Promise<unknown> => {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json();
|
||||
async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: eq(items.id, itemId),
|
||||
columns: { id: true, boardId: true, kind: true, options: true },
|
||||
});
|
||||
if (!item || item.kind !== "customApi") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget item not found" });
|
||||
}
|
||||
const text = await response.text();
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
|
||||
const { definitionId } = parseItemOptions(item.options);
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
if (!definition.enabled) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
}
|
||||
|
||||
return { item, definition, displayConfig: parseDisplayConfig(definition.displayConfig) };
|
||||
}
|
||||
|
||||
const getDecryptedSecrets = (resolved: ResolvedDefinition) =>
|
||||
resolved.definition.secrets.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.value) }));
|
||||
|
||||
const getAuth = (resolved: ResolvedDefinition, mode: "inherit" | "none" = "inherit") =>
|
||||
mode === "none"
|
||||
? undefined
|
||||
: {
|
||||
type: resolved.definition.authType,
|
||||
secrets: getDecryptedSecrets(resolved),
|
||||
headerName: resolved.definition.headerName,
|
||||
};
|
||||
|
||||
const getNetworkScope = (displayConfig: DisplayConfig) =>
|
||||
displayConfig.type === "customJsx" && "jsxApiVersion" in displayConfig && displayConfig.jsxApiVersion === 2
|
||||
? displayConfig.networkScope
|
||||
: ("private" as const);
|
||||
|
||||
const getV2Config = (resolved: ResolvedDefinition) => {
|
||||
const parsed = customJsxDisplayConfigV2Schema.safeParse(resolved.displayConfig);
|
||||
if (!parsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "This template must be reviewed and migrated to named requests",
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const findNamedRequest = (resolved: ResolvedDefinition, requestId: string, kind: "query" | "action") => {
|
||||
const request = getV2Config(resolved).requests.find((candidate) => candidate.id === requestId);
|
||||
if (!request || request.kind !== kind) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Named request not found" });
|
||||
}
|
||||
return request;
|
||||
};
|
||||
|
||||
const getCacheKey = (
|
||||
resolved: ResolvedDefinition,
|
||||
request: CustomJsxRequest,
|
||||
params: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
const digest = hashRuntimeParams(params);
|
||||
return `custom-jsx:${resolved.item.id}:${request.id}:${digest}`;
|
||||
};
|
||||
|
||||
const assertUpstreamSuccess = (response: { ok: boolean; status: number; statusText: string }) => {
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: `API returned ${response.status}: ${response.statusText}` });
|
||||
}
|
||||
};
|
||||
|
||||
const withRequestLimit = async <T>(
|
||||
ctx: RouterContext,
|
||||
resolved: ResolvedDefinition,
|
||||
category: "query" | "action" | "delete",
|
||||
callback: () => Promise<T>,
|
||||
) => {
|
||||
const release = await acquireCustomWidgetRequestLimit({
|
||||
category,
|
||||
userId: ctx.session?.user.id,
|
||||
itemId: resolved.item.id,
|
||||
definitionId: resolved.definition.id,
|
||||
});
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
return await callback();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
};
|
||||
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
}
|
||||
|
||||
if (!definition.enabled) {
|
||||
return { type: "disabled" };
|
||||
}
|
||||
|
||||
let displayConfig: Record<string, unknown>;
|
||||
try {
|
||||
displayConfig = superjson.parse(definition.displayConfig) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
|
||||
}
|
||||
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const { definition, displayConfig } = resolved;
|
||||
|
||||
if (definition.displayType === "actionButton") {
|
||||
return extractActionButtonDisplay(displayConfig);
|
||||
return {
|
||||
...(extractActionButtonDisplay(displayConfig) as Record<string, unknown>),
|
||||
requiresConfirmation: definition.method === "DELETE",
|
||||
};
|
||||
}
|
||||
if (definition.method !== "GET") {
|
||||
return { type: "networkAccessNeedsReview" as const };
|
||||
}
|
||||
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
|
||||
const url = validateUrl(definition.url);
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
|
||||
if (definition.method !== "GET" && definition.requestBody) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: definition.method,
|
||||
headers,
|
||||
body: definition.method !== "GET" ? definition.requestBody : undefined,
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `API returned ${response.status}: ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
|
||||
const json: unknown = await response.json();
|
||||
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
logger.error("Failed to fetch custom API data", { definitionId: input.definitionId, error });
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : "Failed to fetch data from external API",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const response = await withRequestLimit(ctx, resolved, "query", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: definition.url,
|
||||
method: "GET",
|
||||
auth: getAuth(resolved),
|
||||
networkScope: getNetworkScope(displayConfig),
|
||||
kind: "query",
|
||||
cacheKey: `custom-widget:base:${resolved.item.id}`,
|
||||
}),
|
||||
);
|
||||
assertUpstreamSuccess(response);
|
||||
return extractDisplayDataWithFallback(response.data, definition.displayType, displayConfig);
|
||||
}),
|
||||
|
||||
subFetch: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Proxy an HTTP request from a custom JSX widget to an external API on the same domain as the widget definition. Applies the definition's auth credentials. Requires definitionId from the widget configuration.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
definitionId: z.string(),
|
||||
url: z.string().min(1).max(2048),
|
||||
method: z.enum(HTTP_METHODS).default("GET"),
|
||||
body: z.string().max(MAX_BODY_SIZE).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const request = findNamedRequest(resolved, input.requestId, "query");
|
||||
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
|
||||
const response = await withRequestLimit(ctx, resolved, "query", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: resolved.definition.url,
|
||||
targetUrl,
|
||||
method: "GET",
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, request.auth),
|
||||
networkScope: getV2Config(resolved).networkScope,
|
||||
kind: "query",
|
||||
cacheKey: getCacheKey(resolved, request, input.params),
|
||||
cacheTtlSeconds: request.cacheTtlSeconds,
|
||||
}),
|
||||
)
|
||||
);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}),
|
||||
|
||||
executeBaseAction: protectedProcedure
|
||||
.input(itemInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.body && input.body.length > MAX_BODY_SIZE) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Request body exceeds 10KB limit" });
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const { definition } = resolved;
|
||||
if (definition.displayType !== "actionButton") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can use this action" });
|
||||
}
|
||||
|
||||
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, input.definitionId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const permission: BoardPermission = definition.method === "DELETE" ? "full" : "modify";
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), permission);
|
||||
if (definition.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
|
||||
if (!definition.enabled) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
|
||||
const response = await withRequestLimit(ctx, resolved, definition.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: definition.url,
|
||||
method: definition.method,
|
||||
body: definition.method === "GET" ? undefined : (definition.requestBody ?? undefined),
|
||||
auth: getAuth(resolved),
|
||||
networkScope: getNetworkScope(resolved.displayConfig),
|
||||
kind: definition.method === "GET" ? "query" : "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
responseInfo: { status: response.status, statusText: response.statusText },
|
||||
};
|
||||
}),
|
||||
|
||||
executeAction: protectedProcedure
|
||||
.input(namedRequestInputSchema.extend({ confirmed: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const request = findNamedRequest(resolved, input.requestId, "action");
|
||||
if (request.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
|
||||
}
|
||||
|
||||
const decryptedSecrets = definition.secrets.map((s) => ({
|
||||
kind: s.kind,
|
||||
value: decryptSecret(s.value),
|
||||
}));
|
||||
|
||||
const targetUrl = resolveTargetUrl(definition.url, input.url);
|
||||
assertSameOrigin(definition.url, targetUrl);
|
||||
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (input.headers) {
|
||||
for (const [key, value] of Object.entries(input.headers)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.method !== "GET" && input.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
applyAuth(headers, targetUrl, definition.authType, decryptedSecrets, definition.headerName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl.toString(), {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method !== "GET" ? input.body : undefined,
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await parseResponseData(response);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: response.status,
|
||||
data,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true as const, status: response.status, data };
|
||||
} catch (error) {
|
||||
logger.error("SubFetch proxy request failed", { definitionId: input.definitionId, url: input.url, error });
|
||||
return {
|
||||
ok: false as const,
|
||||
status: 0,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : "Request failed",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
eq(boards.id, resolved.item.boardId),
|
||||
request.minimumBoardPermission as BoardPermission,
|
||||
);
|
||||
const targetUrl = renderRequestTarget(resolved.definition.url, request, input.params);
|
||||
const response = await withRequestLimit(ctx, resolved, request.method === "DELETE" ? "delete" : "action", () =>
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl: resolved.definition.url,
|
||||
targetUrl,
|
||||
method: request.method as CustomWidgetMethod,
|
||||
body: renderRequestBody(request.bodyTemplate, input.params),
|
||||
staticHeaders: request.staticHeaders,
|
||||
auth: getAuth(resolved, request.auth),
|
||||
networkScope: getV2Config(resolved).networkScope,
|
||||
kind: "action",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { appRouter } from "../router/app";
|
||||
import { boardRouter } from "../router/board";
|
||||
import { customWidgetRouter } from "../router/custom-widget/custom-widget-router";
|
||||
import { infoRouter } from "../router/info";
|
||||
import { inviteRouter } from "../router/invite";
|
||||
import { serverSettingsRouter } from "../router/serverSettings";
|
||||
@@ -13,6 +14,7 @@ vi.mock("@homarr/auth", () => ({}));
|
||||
const mcpTestRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
info: infoRouter,
|
||||
invite: inviteRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
@@ -34,6 +36,19 @@ test("MCP tools should contain expected procedures", () => {
|
||||
expect(toolNames).toContain("app_create");
|
||||
expect(toolNames).toContain("board_savePartialBoardSettings");
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("customWidget_schema");
|
||||
expect(toolNames).toContain("customWidget_validate");
|
||||
expect(toolNames).toContain("customWidget_all");
|
||||
expect(toolNames).toContain("customWidget_byId");
|
||||
expect(toolNames).toContain("customWidget_readTemplate");
|
||||
expect(toolNames).toContain("customWidget_writeTemplate");
|
||||
expect(toolNames).toContain("customWidget_patchTemplate");
|
||||
expect(toolNames).toContain("customWidget_create");
|
||||
expect(toolNames).toContain("customWidget_import");
|
||||
expect(toolNames).toContain("customWidget_update");
|
||||
expect(toolNames).toContain("customWidget_preview");
|
||||
expect(toolNames).toContain("customWidget_previewQuery");
|
||||
expect(toolNames).toContain("customWidget_simulatePreviewAction");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
expect(toolNames).toContain("serverSettings_getBoardSettings");
|
||||
|
||||
Reference in new issue
Block a user