205 lines
8.9 KiB
TypeScript
205 lines
8.9 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod/v4";
|
|
|
|
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
|
import { decryptSecret } from "@homarr/common/server";
|
|
import {
|
|
getAllTrustedCertificatesAsync,
|
|
getTrustedCertificateHostnamesAsync,
|
|
} from "@homarr/core/infrastructure/certificates";
|
|
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
|
import { CustomWidgetDomainError, executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
|
|
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
|
|
import { eq, inArray } from "@homarr/db";
|
|
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
|
|
import type { IntegrationKind } from "@homarr/definitions";
|
|
|
|
import { protectedProcedure } from "../../trpc";
|
|
|
|
export const integrationRequestSchema = z.object({
|
|
integrationId: z.string().min(1).max(128),
|
|
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
path: z.string().min(1).max(8192),
|
|
body: z.json().optional(),
|
|
confirmed: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("Required for DELETE after the user confirms the deletion and its effects"),
|
|
});
|
|
|
|
export function resolveIntegrationRequestUrl(baseUrl: string, path: string): URL {
|
|
// Reject parser normalization tricks as well as ordinary absolute URLs.
|
|
// eslint-disable-next-line no-control-regex -- Reject URL parser control-character normalization.
|
|
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || /[\\\s\u0000-\u001f\u007f]/u.test(path)) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "path must be a relative URL on the integration origin" });
|
|
}
|
|
try {
|
|
const base = new URL(baseUrl);
|
|
const target = new URL(path, base);
|
|
if (
|
|
!["http:", "https:"].includes(base.protocol) ||
|
|
base.username ||
|
|
base.password ||
|
|
target.origin !== base.origin ||
|
|
target.username ||
|
|
target.password ||
|
|
target.hash
|
|
)
|
|
throw new Error();
|
|
return target;
|
|
} catch {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "path must resolve to the integration origin without credentials or a fragment",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Match the existing adapters; never guess authentication from secret names alone.
|
|
const requestAuth = {
|
|
sonarr: "apiKeyHeader",
|
|
radarr: "apiKeyHeader",
|
|
lidarr: "apiKeyHeader",
|
|
readarr: "apiKeyHeader",
|
|
prowlarr: "apiKeyHeader",
|
|
overseerr: "apiKeyHeader",
|
|
jellyseerr: "apiKeyHeader",
|
|
seerr: "apiKeyHeader",
|
|
immich: "apiKeyHeader",
|
|
slskd: "apiKeyHeader",
|
|
homeAssistant: "bearer",
|
|
coolify: "bearer",
|
|
audiobookshelf: "bearer",
|
|
speedtestTracker: "bearer",
|
|
adGuardHome: "basic",
|
|
nextcloud: "basic",
|
|
} satisfies Partial<Record<IntegrationKind, string>>;
|
|
|
|
export function resolveIntegrationRequestAuth(
|
|
kind: IntegrationKind,
|
|
secrets: CustomWidgetAuthConfig["secrets"],
|
|
): CustomWidgetAuthConfig {
|
|
if (!(kind in requestAuth)) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "This integration's authentication is not supported by integration_request",
|
|
});
|
|
}
|
|
const type = requestAuth[kind as keyof typeof requestAuth];
|
|
let required = ["apiKey"];
|
|
if (type === "basic") required = ["username", "password"];
|
|
if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) {
|
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration credentials are incomplete" });
|
|
}
|
|
return { type, secrets };
|
|
}
|
|
|
|
export function redactIntegrationResponse(data: unknown, secrets: CustomWidgetAuthConfig["secrets"]): unknown {
|
|
const values = secrets.map(({ value }) => value).filter(Boolean);
|
|
const username = secrets.find(({ kind }) => kind === "username")?.value;
|
|
const password = secrets.find(({ kind }) => kind === "password")?.value;
|
|
if (username && password) values.push(`${username}:${password}`);
|
|
const sensitive = [
|
|
...new Set(
|
|
values.flatMap((value) => [
|
|
value,
|
|
JSON.stringify(value).slice(1, -1),
|
|
encodeURIComponent(value),
|
|
Buffer.from(value).toString("base64"),
|
|
]),
|
|
),
|
|
].toSorted((a, b) => b.length - a.length);
|
|
const redact = (value: string) => {
|
|
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
|
|
return value;
|
|
};
|
|
const visit = (value: unknown): unknown => {
|
|
if (typeof value === "string") return redact(value);
|
|
if (
|
|
(typeof value === "number" || typeof value === "boolean" || value === null) &&
|
|
redact(String(value)) !== String(value)
|
|
)
|
|
return "[REDACTED]";
|
|
if (Array.isArray(value)) return value.map(visit);
|
|
if (value !== null && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
|
|
}
|
|
return value;
|
|
};
|
|
return visit(data);
|
|
}
|
|
|
|
export const integrationRequestProcedure = protectedProcedure
|
|
.meta({
|
|
mcp: {
|
|
enabled: true,
|
|
description:
|
|
"Make an authenticated HTTP request to a configured integration using its stored credentials. Get integrationId from integration_all. Every method requires hasFullAccess because arbitrary endpoints can expose service credentials or mutate state. DELETE requires confirmed=true after user confirmation, including effects such as deleting files. Returns {status,data}, including upstream error statuses. Use the service's API documentation to choose the method, relative path and optional JSON body; consult official docs with your browsing tools, or ask the user for API docs if unavailable. Absolute URLs and redirects are rejected. 15 second deadline, 1 MiB response, 10 KiB JSON body. Supports Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud; other authentication schemes are rejected. Never suggest bypassing a permission denial.",
|
|
},
|
|
})
|
|
.input(integrationRequestSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, ctx.session.user.id) });
|
|
const integration = await ctx.db.query.integrations.findFirst({
|
|
where: eq(integrations.id, input.integrationId),
|
|
with: {
|
|
secrets: true,
|
|
userPermissions: { where: eq(integrationUserPermissions.userId, ctx.session.user.id) },
|
|
groupPermissions: {
|
|
where: inArray(
|
|
integrationGroupPermissions.groupId,
|
|
groups.map(({ groupId }) => groupId),
|
|
),
|
|
},
|
|
},
|
|
});
|
|
// Arbitrary GET endpoints can expose service credentials or mutate state.
|
|
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "Missing integration permission: hasFullAccess" });
|
|
}
|
|
const targetUrl = resolveIntegrationRequestUrl(integration.url, input.path);
|
|
if (input.method === "DELETE" && input.confirmed !== true) {
|
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
|
|
}
|
|
if (input.method === "GET" && input.body !== undefined) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "GET requests cannot include a body" });
|
|
}
|
|
// Decrypt only after authorization, using the same snapshot as the URL and permissions.
|
|
// Do not log upstream data or retain error causes.
|
|
try {
|
|
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
|
|
const auth = resolveIntegrationRequestAuth(integration.kind, secrets);
|
|
let tls: CustomWidgetHttpRequest["tls"];
|
|
if (targetUrl.protocol === "https:") {
|
|
const [ca, hostnames] = await Promise.all([
|
|
getAllTrustedCertificatesAsync(),
|
|
getTrustedCertificateHostnamesAsync(),
|
|
]);
|
|
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
|
|
}
|
|
const response = await executeCustomWidgetRequest({
|
|
baseUrl: integration.url,
|
|
targetUrl,
|
|
method: input.method,
|
|
body: JSON.stringify(input.body),
|
|
auth,
|
|
tls,
|
|
networkScope: "loopback",
|
|
kind: "action", // Disable redirects for reads and writes alike; never cache this tool.
|
|
timeoutMs: 15_000,
|
|
textFallback: true,
|
|
});
|
|
return { status: response.status, data: redactIntegrationResponse(response.data, secrets) };
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) throw new TRPCError({ code: error.code, message: error.message });
|
|
if (error instanceof CustomWidgetDomainError) {
|
|
throw new TRPCError({
|
|
code: error.code,
|
|
message: "Integration request failed (timeout, response limit, redirect or invalid target)",
|
|
});
|
|
}
|
|
throw new TRPCError({ code: "BAD_GATEWAY", message: "Integration request failed" });
|
|
}
|
|
});
|