Files
homarr/packages/api/src/router/integration/integration-request.ts
T

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" });
}
});