feat: add authenticated integration request MCP tool (#6835)
This commit is contained in:
1 parent
c32a5317ca
commit
c39a8854aa
10 files changed
+573
-6
No files matched your search
@@ -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": "<Sonarr ID>", "method": "GET", "path": "/api/v3/series" }
|
||||
```
|
||||
|
||||
Deleting series 42 **and its episode files**, after confirmation:
|
||||
|
||||
```json
|
||||
{ "integrationId": "<Sonarr ID>", "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:
|
||||
|
||||
@@ -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<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" });
|
||||
}
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBqzCCAVCgAwIBAgIUdAqiYlhFGtcr9T6hsvClk9u82qUwCgYIKoZIzj0EAwIw
|
||||
GzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDAgFw0yNjA5MTIxNzUwMTNaGA8y
|
||||
MTI2MDgxOTE3NTAxM1owGzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDBZMBMG
|
||||
ByqGSM49AgEGCCqGSM49AwEHA0IABABTFnPW0ov08mlsG71BBDZBDoNE5kA3KkvO
|
||||
Az0lU6EKjz7E2GFVmamqhVK5hwiaVsq5/mY8FbQ/gFLcz2XvJvmjcDBuMB0GA1Ud
|
||||
DgQWBBSS9fRJbILG0XNpKxBCmkVXdjlLADAfBgNVHSMEGDAWgBSS9fRJbILG0XNp
|
||||
KxBCmkVXdjlLADAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEGludGVncmF0
|
||||
aW9uLnRlc3QwCgYIKoZIzj0EAwIDSQAwRgIhAMpKXcPe+rJdM4pD3ujedAhoL8q3
|
||||
r5KDdLIRfcB6eBDaAiEA1jvGgVqurMY2EsUu2+6Z2gizxDlO8CNcC2a3S/YJCsU=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdtCOWMLD3Jpsd+Ly
|
||||
cVh8NDvA13f3idlev6Da4sCGSD6hRANCAAQAUxZz1tKL9PJpbBu9QQQ2QQ6DROZA
|
||||
NypLzgM9JVOhCo8+xNhhVZmpqoVSuYcImlbKuf5mPBW0P4BS3M9l7yb5
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -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<string[]> => []),
|
||||
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<void>((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<void>((resolve, reject) =>
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function fixture(permission?: IntegrationPermission, setup?: (db: ReturnType<typeof createDb>) => Promise<void>) {
|
||||
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<void>((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<void>((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);
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"wud_getStats",
|
||||
],
|
||||
mutation: [
|
||||
"integration_request",
|
||||
"app_create",
|
||||
"app_delete",
|
||||
"app_update",
|
||||
|
||||
@@ -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<string, string> | undefi
|
||||
}
|
||||
}
|
||||
|
||||
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
|
||||
export function createPinnedAgent(
|
||||
addresses: ResolvedAddress[],
|
||||
timeoutMs: number,
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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<ConnectionOptions, "ca" | "checkServerIdentity">;
|
||||
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<CustomWidgetHttpResponse> {
|
||||
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 {
|
||||
|
||||
@@ -45,7 +45,7 @@ async function readLimitedBody(response: Response): Promise<string> {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
export async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
|
||||
const text = await readLimitedBody(response);
|
||||
if (!text) return null;
|
||||
try {
|
||||
@@ -54,7 +54,7 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user