feat: add authenticated integration request MCP tool (#6835)

This commit is contained in:
Thomas authored and GitHub committed 2026-09-13 11:05:23 +02:00
1 parent c32a5317ca
commit c39a8854aa
10 files changed
+573 -6

No files matched your search

+31
View File
@@ -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 The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default. 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 ## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery: 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 { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access"; import { throwIfActionForbiddenAsync } from "./integration-access";
import { integrationRequestProcedure } from "./integration-request";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection"; import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
import { mapTestConnectionError } from "./map-test-connection-error"; import { mapTestConnectionError } from "./map-test-connection-error";
@@ -49,6 +50,7 @@ const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
request: integrationRequestProcedure,
getKinds: publicProcedure getKinds: publicProcedure
.meta({ .meta({
mcp: { 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);
});
});
+1
View File
@@ -87,6 +87,7 @@ const MCP_TOOL_ALLOWLIST = {
"wud_getStats", "wud_getStats",
], ],
mutation: [ mutation: [
"integration_request",
"app_create", "app_create",
"app_delete", "app_delete",
"app_update", "app_update",
@@ -1,6 +1,7 @@
import { lookup } from "node:dns/promises"; import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net"; import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net"; import type { LookupFunction } from "node:net";
import type { ConnectionOptions } from "node:tls";
import { Agent } from "undici"; import { Agent } from "undici";
import type { CustomJsxNetworkScope } from "../core"; 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 customLookup: LookupFunction = (_hostname, options, callback) => {
const family = options.family === 4 || options.family === 6 ? options.family : undefined; const family = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses; 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); else callback(null, selected.address, selected.family);
}; };
return new Agent({ return new Agent({
connect: { lookup: customLookup }, connect: { ...tls, lookup: customLookup },
connectTimeout: timeoutMs, connectTimeout: timeoutMs,
headersTimeout: timeoutMs, headersTimeout: timeoutMs,
bodyTimeout: timeoutMs, bodyTimeout: timeoutMs,
@@ -1,4 +1,5 @@
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import type { ConnectionOptions } from "node:tls";
import { STATUS_CODES } from "node:http"; import { STATUS_CODES } from "node:http";
import { Headers, Response } from "undici"; import { Headers, Response } from "undici";
@@ -52,6 +53,9 @@ export interface CustomWidgetHttpRequest {
auth?: CustomWidgetAuthConfig; auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope; networkScope: CustomJsxNetworkScope;
kind: "query" | "action"; kind: "query" | "action";
timeoutMs?: number;
textFallback?: boolean;
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
cacheKey?: string; cacheKey?: string;
cacheTtlSeconds?: number; cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void; logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
@@ -73,7 +77,10 @@ type RequestHopResult =
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> { async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const controller = new AbortController(); 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 { try {
return await performRequestWithinDeadline(input, controller.signal); return await performRequestWithinDeadline(input, controller.signal);
} catch (error) { } catch (error) {
@@ -113,6 +120,7 @@ async function performRequestWithinDeadline(
const dispatcher = createPinnedAgent( const dispatcher = createPinnedAgent(
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }), await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS,
input.tls,
); );
const headers = buildHeaders(input, currentUrl, currentBody); const headers = buildHeaders(input, currentUrl, currentBody);
const controller = new AbortController(); const controller = new AbortController();
@@ -141,7 +149,7 @@ async function performRequestWithinDeadline(
ok: response.ok, ok: response.ok,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
data: await parseResponseBody(response), data: await parseResponseBody(response, input.textFallback),
}, },
}; };
} else { } else {
@@ -45,7 +45,7 @@ async function readLimitedBody(response: Response): Promise<string> {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes); 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); const text = await readLimitedBody(response);
if (!text) return null; if (!text) return null;
try { try {
@@ -54,7 +54,7 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
return json; return json;
} catch (error) { } catch (error) {
if (error instanceof CustomWidgetDomainError) throw 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" }); throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
} }
return text; return text;