fix: share integration transport and enforce access

This commit is contained in:
ajnart committed 2026-09-12 20:26:39 +02:00
1 parent 52c82f72db
commit c2a587bda7
16 files changed
+284 -117

No files matched your search

@@ -1,42 +1,33 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { decryptSecret } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { integrations } from "@homarr/db/schema";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { customWidgetIntegrationKinds, getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import { getCustomWidgetIntegrationConnection } from "@homarr/integrations/custom-widget-source";
import type { integrationHttpAuth } from "@homarr/integrations/http-auth";
import { throwIfActionForbiddenAsync } from "../integration/integration-access";
import type { IntegrationHttpContext } from "../integration/integration-http";
import {
getIntegrationForHttpRequest,
getIntegrationHttpCacheVersion,
getIntegrationHttpConnection,
} from "../integration/integration-http";
interface SourceContext {
db: Database;
session: Session | null;
}
// Keep the portable schema's supported types within the server authentication adapters.
customWidgetIntegrationKinds satisfies readonly (keyof typeof integrationHttpAuth)[];
export async function assertCustomWidgetIntegrationBindings(
ctx: SourceContext,
ctx: IntegrationHttpContext,
sources: Record<string, CustomWidgetSource>,
) {
for (const source of Object.values(sources)) {
if (source.type !== "integration" || !source.integrationId) continue;
await resolveIntegration(ctx, source, "full");
await resolveIntegration(ctx, source);
}
}
async function resolveIntegration(
ctx: SourceContext,
ctx: IntegrationHttpContext,
source: Extract<CustomWidgetSource, { type: "integration" }>,
permission: "use" | "interact" | "full",
) {
if (!source.integrationId) {
throw new TRPCError({
@@ -44,11 +35,7 @@ async function resolveIntegration(
message: "Select an existing integration for this widget source",
});
}
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, source.integrationId), permission);
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, source.integrationId),
with: { secrets: true },
});
const integration = await getIntegrationForHttpRequest(ctx, source.integrationId);
if (!integration || integration.kind !== source.integrationKind) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
@@ -59,12 +46,14 @@ async function resolveIntegration(
}
export async function resolveCustomWidgetSource(
ctx: SourceContext,
ctx: IntegrationHttpContext,
source: CustomWidgetSource,
request: Pick<CustomJsxRequest, "kind" | "method" | "auth">,
getSecrets: () => Array<{ kind: string; value: string }>,
): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix"> & { cacheVersion: string }
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets"> & {
cacheVersion: string;
}
> {
if (source.type !== "integration") {
const authType = getCustomWidgetSourceAuthType(source);
@@ -75,32 +64,26 @@ export async function resolveCustomWidgetSource(
}
return { baseUrl: source.baseUrl, networkScope: source.networkScope, auth, cacheVersion: "" };
}
let permission = "use" as "use" | "interact" | "full";
if (request.kind === "action" || request.method !== "GET") permission = "interact";
if (request.method === "DELETE") permission = "full";
const integration = await resolveIntegration(ctx, source, permission);
const connection = getCustomWidgetIntegrationConnection({
...integration,
externalUrl: null,
decryptedSecrets: integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) })),
});
let tls: CustomWidgetHttpRequest["tls"];
if (new URL(connection.baseUrl).protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
const integration = await resolveIntegration(ctx, source);
const connection = await getIntegrationHttpConnection(integration);
return {
...connection,
auth: request.auth === "none" ? undefined : connection.auth,
networkScope: "loopback",
pathPrefix: new URL(connection.baseUrl).pathname,
tls,
cacheVersion: createHash("sha256")
.update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets]))
.digest("hex")
.slice(0, 16),
};
}
export async function getCustomWidgetIntegrationCacheVersions(
ctx: IntegrationHttpContext,
sources: Record<string, CustomWidgetSource>,
) {
return Promise.all(
Object.entries(sources).flatMap(([sourceId, source]) => {
if (source.type !== "integration") return [];
return [
resolveIntegration(ctx, source)
.then((integration) => `${sourceId}:${getIntegrationHttpCacheVersion(integration)}`)
.catch(() => `${sourceId}:unavailable`),
];
}),
);
}
@@ -0,0 +1,84 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
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 type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import { IntegrationHttpAuthError, resolveIntegrationHttpAuth } from "@homarr/integrations/http-auth";
export interface IntegrationHttpContext {
db: Database;
session: Session | null;
}
export async function getIntegrationForHttpRequest(ctx: IntegrationHttpContext, integrationId: string) {
const userId = ctx.session?.user.id ?? "";
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId) });
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, integrationId),
with: {
secrets: true,
userPermissions: { where: eq(integrationUserPermissions.userId, userId) },
groupPermissions: {
where: inArray(integrationGroupPermissions.groupId, groups.map(({ groupId }) => groupId).concat("")),
},
},
});
// An arbitrary read can expose service credentials or perform a mutation. Native
// integration use/interact permissions authorize only Homarr's bounded operations.
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Arbitrary integration requests require full integration access",
});
}
return integration;
}
type HttpIntegration = Awaited<ReturnType<typeof getIntegrationForHttpRequest>>;
export function getIntegrationHttpCacheVersion(integration: HttpIntegration) {
return createHash("sha256")
.update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets]))
.digest("hex")
.slice(0, 16);
}
export async function getIntegrationHttpConnection(integration: HttpIntegration) {
try {
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
const auth = resolveIntegrationHttpAuth(integration.kind, secrets);
const url = new URL(integration.url);
let tls: CustomWidgetHttpRequest["tls"];
if (url.protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
return {
baseUrl: integration.url,
auth,
tls,
networkScope: "loopback" as const,
pathPrefix: url.pathname,
redactSecrets: secrets,
cacheVersion: getIntegrationHttpCacheVersion(integration),
};
} catch (error) {
if (error instanceof IntegrationHttpAuthError) {
throw new TRPCError({ code: error.code, message: error.message });
}
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration connection could not be resolved" });
}
}
@@ -1,4 +1,4 @@
import { resolveCustomWidgetSource } from "../custom-widget/source-resolver";
import { getCustomWidgetIntegrationCacheVersions, resolveCustomWidgetSource } from "../custom-widget/source-resolver";
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
@@ -173,7 +173,7 @@ const executeRequest = async (
cacheTtlSeconds: request.cacheSeconds,
}),
);
return { ...response, connectionCacheVersion: connection.cacheVersion };
return response;
};
export const customApiRouter = createTRPCRouter({
@@ -186,6 +186,7 @@ export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const integrationVersions = await getCustomWidgetIntegrationCacheVersions(ctx, resolved.definition.sources);
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
@@ -197,7 +198,6 @@ export const customApiRouter = createTRPCRouter({
requestId,
{
data: response.data,
connectionCacheVersion: response.connectionCacheVersion,
status: {
loading: false,
ok: response.ok,
@@ -212,7 +212,6 @@ export const customApiRouter = createTRPCRouter({
requestId,
{
data: null,
connectionCacheVersion: "",
status: {
loading: false,
ok: false,
@@ -228,7 +227,7 @@ export const customApiRouter = createTRPCRouter({
return {
type: "customJsx" as const,
template: resolved.definition.template,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${entries.map(([, result]) => result.connectionCacheVersion).join(":")}`,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationVersions.join(":")}`,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,