diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index d4581abf8..3dbd3704e 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -70,6 +70,37 @@ through MCP. The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval before mutations by default. +## Authenticated integration requests + +`integration_request` calls API endpoints without a dedicated tool, using stored integration credentials. +Get `integrationId` from `integration_all`. Every method requires **full integration access**: arbitrary GET +endpoints can expose credentials or change state. DELETE additionally requires `confirmed: true` after user confirmation. + +Ask your agent to consult the service's official API docs with its browsing tools, or provide the endpoint contract. +For example, using the [Sonarr API docs](https://sonarr.tv/docs/api/#v3): + +```json +{ "integrationId": "", "method": "GET", "path": "/api/v3/series" } +``` + +Deleting series 42 **and its episode files**, after confirmation: + +```json +{ "integrationId": "", "method": "DELETE", "path": "/api/v3/series/42?deleteFiles=true", "confirmed": true } +``` + +Supports GET, POST, PUT, PATCH and DELETE with an optional JSON `body` (10 KiB maximum; not allowed for GET). +Returns `{status, data}` with parsed JSON, text, or `null` for an empty body, including upstream error responses. +Known credentials are redacted. Requests have a 15-second total deadline, a 10-second per-request timeout and a +1 MiB response limit. HTTPS uses Homarr's trusted certificates and hostname exceptions. + +Paths follow `new URL(path, integration.url)`: `/api/...` starts at the origin root; `api/...` resolves relative to +the configured URL's directory. Absolute URLs, protocol-relative paths, credentials, fragments and redirects are rejected. + +Supported integrations: 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. The built-in Assistant's default mutation approval applies to this tool, including GET calls. + ## Verify the connection This request lists tools through the older-client compatibility path. It checks authentication and tool discovery: diff --git a/packages/api/src/router/integration/integration-request.ts b/packages/api/src/router/integration/integration-request.ts new file mode 100644 index 000000000..161a1419c --- /dev/null +++ b/packages/api/src/router/integration/integration-request.ts @@ -0,0 +1,204 @@ +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>; + +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" }); + } + }); diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index a36ff5c6e..f48b71af3 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -42,6 +42,7 @@ import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/va import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "./integration-access"; +import { integrationRequestProcedure } from "./integration-request"; import { MissingSecretError, testConnectionAsync } from "./integration-test-connection"; import { mapTestConnectionError } from "./map-test-connection-error"; @@ -49,6 +50,7 @@ const logger = createLogger({ module: "integrationRouter" }); const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); export const integrationRouter = createTRPCRouter({ + request: integrationRequestProcedure, getKinds: publicProcedure .meta({ mcp: { diff --git a/packages/api/src/test/fixtures/integration-request-cert.pem b/packages/api/src/test/fixtures/integration-request-cert.pem new file mode 100644 index 000000000..b4e67f8de --- /dev/null +++ b/packages/api/src/test/fixtures/integration-request-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBqzCCAVCgAwIBAgIUdAqiYlhFGtcr9T6hsvClk9u82qUwCgYIKoZIzj0EAwIw +GzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDAgFw0yNjA5MTIxNzUwMTNaGA8y +MTI2MDgxOTE3NTAxM1owGzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABABTFnPW0ov08mlsG71BBDZBDoNE5kA3KkvO +Az0lU6EKjz7E2GFVmamqhVK5hwiaVsq5/mY8FbQ/gFLcz2XvJvmjcDBuMB0GA1Ud +DgQWBBSS9fRJbILG0XNpKxBCmkVXdjlLADAfBgNVHSMEGDAWgBSS9fRJbILG0XNp +KxBCmkVXdjlLADAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEGludGVncmF0 +aW9uLnRlc3QwCgYIKoZIzj0EAwIDSQAwRgIhAMpKXcPe+rJdM4pD3ujedAhoL8q3 +r5KDdLIRfcB6eBDaAiEA1jvGgVqurMY2EsUu2+6Z2gizxDlO8CNcC2a3S/YJCsU= +-----END CERTIFICATE----- diff --git a/packages/api/src/test/fixtures/integration-request-key.pem b/packages/api/src/test/fixtures/integration-request-key.pem new file mode 100644 index 000000000..d00243d8b --- /dev/null +++ b/packages/api/src/test/fixtures/integration-request-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdtCOWMLD3Jpsd+Ly +cVh8NDvA13f3idlev6Da4sCGSD6hRANCAAQAUxZz1tKL9PJpbBu9QQQ2QQ6DROZA +NypLzgM9JVOhCo8+xNhhVZmpqoVSuYcImlbKuf5mPBW0P4BS3M9l7yb5 +-----END PRIVATE KEY----- diff --git a/packages/api/src/test/integration-request.spec.ts b/packages/api/src/test/integration-request.spec.ts new file mode 100644 index 000000000..d49dbed8b --- /dev/null +++ b/packages/api/src/test/integration-request.spec.ts @@ -0,0 +1,300 @@ +import { readFileSync } from "node:fs"; +import { X509Certificate } from "node:crypto"; +import { createServer as createHttpsServer } from "node:https"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { encryptSecret } from "@homarr/common/server"; +import { executeCustomWidgetRequest } from "@homarr/custom-widgets/server"; +import { eq } from "@homarr/db"; +import { createDb } from "@homarr/db/test"; +import { integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; +import type { IntegrationPermission } from "@homarr/definitions"; + +import { createTRPCRouter } from "../trpc"; +import { + integrationRequestProcedure, + integrationRequestSchema, + redactIntegrationResponse, + resolveIntegrationRequestAuth, + resolveIntegrationRequestUrl, +} from "../router/integration/integration-request"; + +vi.mock("@homarr/auth", () => ({})); +const trust = vi.hoisted(() => ({ + certificates: vi.fn(async (): Promise => []), + hostnames: vi.fn(async (): Promise<{ hostname: string; thumbprint: string }[]> => []), +})); +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getAllTrustedCertificatesAsync: trust.certificates, + getTrustedCertificateHostnamesAsync: trust.hostnames, +})); + +const router = createTRPCRouter({ request: integrationRequestProcedure }); +const secret = "test-credential-not-for-output"; +const calls: { method: string | undefined; path: string | undefined; auth: string | undefined; body: string }[] = []; +const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += String(chunk); + calls.push({ method: req.method, path: req.url, auth: req.headers["x-api-key"] as string | undefined, body }); + if (req.url === "/slow") return; + if (req.url === "/redirect") { + res.writeHead(302, { Location: "/echo" }); + res.end(); + return; + } + if (req.url === "/large") { + res.end("x".repeat(1024 * 1024 + 1)); + return; + } + if (req.url === "/echo") { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ [secret]: [secret, encodeURIComponent(secret), Buffer.from(secret).toString("base64")] })); + return; + } + if (req.url === "/text") { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(`invalid JSON ${secret}`); + return; + } + if (req.method === "DELETE") { + res.writeHead(204); + res.end(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify([{ id: 42, title: "Example series" }])); +}); +let baseUrl: string; +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); +afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => { + if (error) reject(error); + else resolve(); + }), + ); +}); + +async function fixture(permission?: IntegrationPermission, setup?: (db: ReturnType) => Promise) { + const db = createDb(); + await db.insert(users).values([{ id: "reader" }, { id: "other" }]); + await db.insert(integrations).values({ id: "sonarr", name: "Sonarr", kind: "sonarr", url: baseUrl }); + await db.insert(integrationSecrets).values({ integrationId: "sonarr", kind: "apiKey", value: encryptSecret(secret) }); + // Another user's full permission must never grant this caller access. + await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "other", permission: "full" }); + if (permission) + await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "reader", permission }); + const session = { + user: { id: "reader", permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), + } satisfies Session; + await setup?.(db); + return router.createCaller({ db, session, deviceType: undefined }); +} + +const get = { integrationId: "sonarr", method: "GET", path: "/api/v3/series" } as const; + +describe("integration_request validation", () => { + test.each([ + "https://evil.com/x", + "https://example.com/x", + "//evil.com/x", + "\\\\evil.com/x", + "/\\evil.com", + " https://evil.com", + "https:\nevil.com", + "/x#fragment", + ])("rejects %s", (path) => { + expect(() => resolveIntegrationRequestUrl("https://example.com/base/", path)).toThrow(); + }); + test.each(["../series", "../../series", "%2e%2e/series"])("normalizes same-origin traversal %s", (path) => { + expect(resolveIntegrationRequestUrl("https://example.com/base/", path).href).toBe("https://example.com/series"); + }); + test("preserves URL resolution semantics and query parameters", () => { + expect(resolveIntegrationRequestUrl("https://example.com/base/", "series?id=42").href).toBe( + "https://example.com/base/series?id=42", + ); + expect(resolveIntegrationRequestUrl("https://example.com/base/", "/series").pathname).toBe("/series"); + }); + test("accepts only JSON bodies and the specified methods", () => { + expect(integrationRequestSchema.safeParse({ ...get, body: new Date() }).success).toBe(false); + expect(integrationRequestSchema.safeParse({ ...get, method: "HEAD" }).success).toBe(false); + }); + test("resolves supported auth and rejects unsupported or incomplete credentials", () => { + expect(resolveIntegrationRequestAuth("sonarr", [{ kind: "apiKey", value: secret }]).type).toBe("apiKeyHeader"); + expect(resolveIntegrationRequestAuth("homeAssistant", [{ kind: "apiKey", value: secret }]).type).toBe("bearer"); + expect( + resolveIntegrationRequestAuth("adGuardHome", [ + { kind: "username", value: "user" }, + { kind: "password", value: secret }, + ]).type, + ).toBe("basic"); + expect(() => resolveIntegrationRequestAuth("qBittorrent", [])).toThrow("not supported"); + expect(() => resolveIntegrationRequestAuth("sonarr", [])).toThrow("incomplete"); + }); + test.each([42, true, false, null])("redacts echoed primitive credential %s", (value) => { + expect(redactIntegrationResponse(value, [{ kind: "password", value: String(value) }])).toBe("[REDACTED]"); + }); + test("redacts JSON-escaped credentials embedded in text", () => { + const password = 'quoted"password\\with-newline\n'; + const encoded = JSON.stringify(password).slice(1, -1); + expect( + redactIntegrationResponse({ message: `password=${encoded}` }, [{ kind: "password", value: password }]), + ).toEqual({ message: "password=[REDACTED]" }); + }); + test("redacts basic credentials and JSON keys without damaging JSON", () => { + const credentials = Buffer.from(`user:${secret}`).toString("base64"); + expect( + redactIntegrationResponse({ [secret]: `Basic ${credentials}` }, [ + { kind: "username", value: "user" }, + { kind: "password", value: secret }, + ]), + ).toEqual({ "[REDACTED]": "Basic [REDACTED]" }); + }); +}); + +describe("integration_request with a mock integration server", () => { + test("GET authenticates with stored credentials and full permission", async () => { + const caller = await fixture("full"); + await expect(caller.request(get)).resolves.toEqual({ status: 200, data: [{ id: 42, title: "Example series" }] }); + expect(calls.at(-1)).toMatchObject({ method: "GET", path: "/api/v3/series", auth: secret }); + }); + test("keeps credentials and URL from the same integration snapshot", async () => { + const rotated = "credential-for-the-new-origin"; + const caller = await fixture("full", async (db) => { + const snapshot = await db.query.integrations.findFirst({ + where: eq(integrations.id, "sonarr"), + with: { + secrets: true, + userPermissions: { where: eq(integrationUserPermissions.userId, "reader") }, + groupPermissions: true, + }, + }); + // Simulate a URL/key rotation immediately after the first SELECT completed. + await db.update(integrations).set({ url: "https://new-origin.example" }).where(eq(integrations.id, "sonarr")); + await db.update(integrationSecrets).set({ value: encryptSecret(rotated) }); + vi.spyOn(db.query.integrations, "findFirst").mockResolvedValueOnce(snapshot); + }); + await caller.request(get); + expect(calls.at(-1)?.auth).toBe(secret); + }); + test("uses Homarr's trusted certificates and hostname exceptions without disabling TLS checks", async () => { + // This committed certificate/key pair is only for the loopback mock server. + const cert = readFileSync(new URL("./fixtures/integration-request-cert.pem", import.meta.url), "utf8"); + const key = readFileSync(new URL("./fixtures/integration-request-key.pem", import.meta.url), "utf8"); + const https = createHttpsServer({ cert, key }, (_req, res) => res.end('{"trusted":true}')); + await new Promise((resolve) => https.listen(0, "127.0.0.1", resolve)); + try { + const caller = await fixture("full", async (db) => { + await db.update(integrations).set({ url: `https://127.0.0.1:${(https.address() as AddressInfo).port}` }); + }); + await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" }); + trust.certificates.mockResolvedValue([cert]); + await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" }); + trust.hostnames.mockResolvedValue([ + { hostname: "127.0.0.1", thumbprint: new X509Certificate(cert).fingerprint256 }, + ]); + await expect(caller.request(get)).resolves.toEqual({ status: 200, data: { trusted: true } }); + } finally { + trust.certificates.mockResolvedValue([]); + trust.hostnames.mockResolvedValue([]); + https.closeAllConnections(); + await new Promise((resolve) => https.close(() => resolve())); + } + }); + test("denies GET without full permission even when another user has full access", async () => { + const caller = await fixture(); + const before = calls.length; + await expect(caller.request(get)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Missing integration permission: hasFullAccess", + }); + expect(calls).toHaveLength(before); + }); + test.each(["GET", "POST", "PUT", "PATCH", "DELETE"] as const)( + "denies %s with only use permission", + async (method) => { + const caller = await fixture("use"); + const before = calls.length; + await expect(caller.request({ ...get, method, confirmed: true })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Missing integration permission: hasFullAccess", + }); + expect(calls).toHaveLength(before); + }, + ); + test("interact access does not authorize arbitrary endpoints", async () => { + const caller = await fixture("interact"); + await expect(caller.request(get)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Missing integration permission: hasFullAccess", + }); + }); + test("DELETE requires confirmation and preserves deleteFiles", async () => { + const caller = await fixture("full"); + const input = { ...get, method: "DELETE", path: "/api/v3/series/42?deleteFiles=true" } as const; + const before = calls.length; + await expect(caller.request(input)).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + expect(calls).toHaveLength(before); + await expect(caller.request({ ...input, confirmed: true })).resolves.toEqual({ status: 204, data: null }); + expect(calls.at(-1)).toMatchObject({ method: "DELETE", path: input.path, auth: secret }); + }); + test("sends a JSON body with full access", async () => { + const caller = await fixture("full"); + await caller.request({ ...get, method: "PATCH", body: { monitored: false } }); + expect(calls.at(-1)?.body).toBe('{"monitored":false}'); + }); + test.each(["https://evil.com/x", "//evil.com/x"])("rejects %s before HTTP", async (path) => { + const caller = await fixture("full"); + const before = calls.length; + await expect(caller.request({ ...get, path })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(calls).toHaveLength(before); + }); + test("never follows a redirect", async () => { + const caller = await fixture("full"); + const before = calls.length; + await expect(caller.request({ ...get, path: "/redirect" })).rejects.toMatchObject({ code: "BAD_GATEWAY" }); + expect(calls).toHaveLength(before + 1); + }); + test("enforces the response and request caps", async () => { + const caller = await fixture("full"); + await expect(caller.request({ ...get, path: "/large" })).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" }); + await expect(caller.request({ ...get, method: "POST", body: "x".repeat(10 * 1024) })).rejects.toMatchObject({ + code: "PAYLOAD_TOO_LARGE", + }); + }); + test("redacts upstream JSON and text, including error responses", async () => { + const caller = await fixture("full"); + await expect(caller.request({ ...get, path: "/echo" })).resolves.toEqual({ + status: 400, + data: { "[REDACTED]": ["[REDACTED]", "[REDACTED]", "[REDACTED]"] }, + }); + await expect(caller.request({ ...get, path: "/text" })).resolves.toEqual({ + status: 500, + data: "invalid JSON [REDACTED]", + }); + }); + test("honors the executor's total deadline without logging credentials", async () => { + const logError = vi.fn(); + await expect( + executeCustomWidgetRequest({ + baseUrl, + targetUrl: `${baseUrl}/slow`, + method: "GET", + networkScope: "loopback", + kind: "action", + timeoutMs: 30, + auth: { type: "apiKeyHeader", secrets: [{ kind: "apiKey", value: secret }] }, + logError, + }), + ).rejects.toMatchObject({ code: "BAD_GATEWAY", reason: "timeout" }); + expect(JSON.stringify(logError.mock.calls)).not.toContain(secret); + }); +}); diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 4644617c8..d9508bce6 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -87,6 +87,7 @@ const MCP_TOOL_ALLOWLIST = { "wud_getStats", ], mutation: [ + "integration_request", "app_create", "app_delete", "app_update", diff --git a/packages/custom-widgets/src/server/network-policy.ts b/packages/custom-widgets/src/server/network-policy.ts index 39064a7f5..21e1e0588 100644 --- a/packages/custom-widgets/src/server/network-policy.ts +++ b/packages/custom-widgets/src/server/network-policy.ts @@ -1,6 +1,7 @@ import { lookup } from "node:dns/promises"; import { BlockList, isIP } from "node:net"; import type { LookupFunction } from "node:net"; +import type { ConnectionOptions } from "node:tls"; import { Agent } from "undici"; import type { CustomJsxNetworkScope } from "../core"; @@ -191,7 +192,11 @@ export function assertSafeStaticHeaders(headers: Record | undefi } } -export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) { +export function createPinnedAgent( + addresses: ResolvedAddress[], + timeoutMs: number, + tls?: Pick, +) { const customLookup: LookupFunction = (_hostname, options, callback) => { const family = options.family === 4 || options.family === 6 ? options.family : undefined; const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses; @@ -204,7 +209,7 @@ export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: numbe else callback(null, selected.address, selected.family); }; return new Agent({ - connect: { lookup: customLookup }, + connect: { ...tls, lookup: customLookup }, connectTimeout: timeoutMs, headersTimeout: timeoutMs, bodyTimeout: timeoutMs, diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index db7976761..df8627765 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -1,4 +1,5 @@ import { Buffer } from "node:buffer"; +import type { ConnectionOptions } from "node:tls"; import { STATUS_CODES } from "node:http"; import { Headers, Response } from "undici"; @@ -52,6 +53,9 @@ export interface CustomWidgetHttpRequest { auth?: CustomWidgetAuthConfig; networkScope: CustomJsxNetworkScope; kind: "query" | "action"; + timeoutMs?: number; + textFallback?: boolean; + tls?: Pick; cacheKey?: string; cacheTtlSeconds?: number; logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void; @@ -73,7 +77,10 @@ type RequestHopResult = async function performRequest(input: CustomWidgetHttpRequest): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS); + const timeout = setTimeout( + () => controller.abort(), + Math.min(input.timeoutMs ?? MAX_REQUEST_DURATION_MS, MAX_REQUEST_DURATION_MS), + ); try { return await performRequestWithinDeadline(input, controller.signal); } catch (error) { @@ -113,6 +120,7 @@ async function performRequestWithinDeadline( const dispatcher = createPinnedAgent( await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }), REQUEST_TIMEOUT_MS, + input.tls, ); const headers = buildHeaders(input, currentUrl, currentBody); const controller = new AbortController(); @@ -141,7 +149,7 @@ async function performRequestWithinDeadline( ok: response.ok, status: response.status, statusText: response.statusText, - data: await parseResponseBody(response), + data: await parseResponseBody(response, input.textFallback), }, }; } else { diff --git a/packages/custom-widgets/src/server/response.ts b/packages/custom-widgets/src/server/response.ts index dec549d17..9be42fc11 100644 --- a/packages/custom-widgets/src/server/response.ts +++ b/packages/custom-widgets/src/server/response.ts @@ -45,7 +45,7 @@ async function readLimitedBody(response: Response): Promise { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } -export async function parseResponseBody(response: Response): Promise { +export async function parseResponseBody(response: Response, textFallback = false): Promise { const text = await readLimitedBody(response); if (!text) return null; try { @@ -54,7 +54,7 @@ export async function parseResponseBody(response: Response): Promise { return json; } catch (error) { if (error instanceof CustomWidgetDomainError) throw error; - if (response.headers.get("content-type")?.toLowerCase().includes("json")) { + if (!textFallback && response.headers.get("content-type")?.toLowerCase().includes("json")) { throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" }); } return text;