openwebui: enable user scoped integrations to avoid cross user data leakage

This commit is contained in:
Lok3sh-Kumar committed 2026-07-01 20:55:58 +05:30
1 parent 3bb6037d35
commit 6f6a989ba6
17 files changed
+2731 -10

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
turbopackFileSystemCacheForDev: false,
preloadEntriesOnStart: false,
webpackMemoryOptimizations: true,
},
@@ -0,0 +1,12 @@
"use client";
import dynamic from "next/dynamic";
const FloatingChatButton = dynamic(
() => import("~/components/board/floating-chat-button").then((mod) => mod.FloatingChatButton),
{ ssr: false },
);
export const FloatingChatButtonProvider = () => {
return <FloatingChatButton />;
};
+2
View File
@@ -32,6 +32,7 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { FloatingChatButtonProvider } from "./_client-providers/floating-chat";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
import { AuthProvider } from "./_client-providers/session";
@@ -147,6 +148,7 @@ export default async function Layout(props: {
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
<FloatingChatButtonProvider />
{props.children}
</StackedProvider>
</body>
@@ -0,0 +1,131 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Box, Dialog, Modal } from "@mantine/core";
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useI18n } from "@homarr/translation/client";
import { Chat } from "@homarr/widgets/_open-webui/chat";
export const FloatingChatButton = () => {
const t = useI18n();
const { data: session } = useSession();
const [opened, { open, close }] = useDisclosure(false);
const isMobile = useMediaQuery("(max-width: 767px)");
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: !!session?.user?.id,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
// Find the first OpenWebUI integration where the current user is the creator.
const myOpenWebUiIntegration = useMemo(() => {
if (!session?.user?.id) return null;
return integrations.find(
(integration) =>
integration.kind === "openWebUi" && integration.creatorId === session.user.id,
);
}, [integrations, session?.user?.id]);
if (!myOpenWebUiIntegration) return null;
return (
<>
{!opened && (
<ActionIcon
size={56}
radius="xl"
variant="filled"
onClick={open}
aria-label={t("widget.openWebUi.name")}
style={{
position: "fixed",
bottom: 24,
right: 24,
zIndex: 1000,
boxShadow: "var(--mantine-shadow-lg)",
}}
>
<IconRobot size={28} />
</ActionIcon>
)}
{isMobile ? (
<Modal
opened={opened}
onClose={close}
title={t("widget.openWebUi.name")}
fullScreen
styles={{
body: {
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
options={{
systemPrompt: "",
showHistory: true,
}}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the floating chat.
}}
width={800}
height={600}
/>
</Box>
</Modal>
) : (
<Dialog
opened={opened}
onClose={close}
withCloseButton
size="35vw"
position={{ bottom: 100, right: 24 }}
transitionProps={{ transition: "pop-top-right", duration: 200 }}
styles={{
root: {
height: "50dvh",
display: "flex",
flexDirection: "column",
padding: 0,
overflow: "hidden",
},
closeButton: {
zIndex: 10,
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
options={{
systemPrompt: "",
showHistory: true,
}}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the floating chat.
}}
width={800}
height={600}
/>
</Box>
</Dialog>
)}
</>
);
};
@@ -99,6 +99,7 @@ export const integrationRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
url: integration.url,
creatorId: integration.creatorId,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
@@ -350,6 +351,15 @@ export const integrationRouter = createTRPCRouter({
url: input.url,
kind: input.kind,
appId,
creatorId: ctx.session.user.id,
});
// Auto-grant the creator "full" permission so they can immediately use
// and manage the integration they just created.
await ctx.db.insert(integrationUserPermissions).values({
integrationId,
userId: ctx.session.user.id,
permission: "full",
});
if (input.secrets.length >= 1) {
@@ -301,6 +301,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -317,10 +321,18 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
// Creator should be auto-granted "full" permission
expect(dbPermission).toBeDefined();
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
expect(dbPermission!.userId).toBe(defaultUserId);
expect(dbPermission!.permission).toBe("full");
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -336,6 +348,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
@@ -357,6 +373,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -374,6 +391,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -405,6 +426,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
@@ -431,6 +453,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -455,6 +481,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
+45 -7
View File
@@ -1,4 +1,5 @@
import { observable } from "@trpc/server/observable";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -19,6 +20,24 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
const logger = createLogger({ module: "openWebUiRouter" });
/**
* Open WebUI integrations hold a personal API key that grants access to a
* single user's data (chat history, files, notes, knowledge bases). To prevent
* other Homarr users from reading that data, every procedure that touches the
* integration verifies that the caller is the integration's creator.
*
* The check is intentionally placed **after** the integration middleware (which
* already validates the caller's permission level) so the error message does
* not leak whether the integration exists.
*/
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
// Existing integrations that predate the creatorId column have a null
// creatorId; we skip the check for backward compatibility.
if (integration.creatorId && integration.creatorId !== userId) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
}
};
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
@@ -104,30 +123,39 @@ const groundMessagesAsync = async (
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getChats: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getKnowledge: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getFiles: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
getNotes: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
@@ -137,6 +165,7 @@ export const openWebUiRouter = createTRPCRouter({
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
@@ -146,6 +175,7 @@ export const openWebUiRouter = createTRPCRouter({
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
knowledgeId: input.knowledgeId,
});
@@ -159,6 +189,7 @@ export const openWebUiRouter = createTRPCRouter({
.input(z.object({ url: z.string().url() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.processWebAsync(input.url);
}),
@@ -169,6 +200,7 @@ export const openWebUiRouter = createTRPCRouter({
.input(z.object({ filename: z.string(), contentBase64: z.string(), contentType: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
}),
@@ -178,15 +210,18 @@ export const openWebUiRouter = createTRPCRouter({
.input(z.object({ filename: z.string(), contentBase64: z.string(), contentType: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
return { text };
}),
getChat: publicProcedure
// Full chat messages contain private user data; restrict to the creator.
getChat: protectedProcedure
.input(z.object({ chatId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
@@ -196,6 +231,7 @@ export const openWebUiRouter = createTRPCRouter({
.input(chatPayloadSchema)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.createChatAsync({
title: input.title,
@@ -210,6 +246,7 @@ export const openWebUiRouter = createTRPCRouter({
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.updateChatAsync(input.chatId, input.chat);
await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync();
@@ -237,6 +274,7 @@ export const openWebUiRouter = createTRPCRouter({
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.subscription(({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
return observable<StreamEvent>((emit) => {
const controller = new AbortController();
@@ -11,6 +11,7 @@ interface IntegrationContextProps {
name: string;
url: string;
kind: IntegrationKind;
creatorId: string | null;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -15,6 +15,7 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
name: true,
url: true,
kind: true,
creatorId: true,
},
with: {
userPermissions: {
@@ -0,0 +1 @@
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id);
File diff suppressed because it is too large. Load diff
@@ -288,6 +288,13 @@
"when": 1782836728121,
"tag": "0040_charming_tag",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1782837354691,
"tag": "0041_adorable_marten_broadcloak",
"breakpoints": true
}
]
}
+5
View File
@@ -209,6 +209,7 @@ export const integrations = mysqlTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -669,6 +670,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5
View File
@@ -208,6 +208,7 @@ export const integrations = pgTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -668,6 +669,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5
View File
@@ -195,6 +195,7 @@ export const integrations = sqliteTable(
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -658,6 +659,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+2 -1
View File
@@ -20,7 +20,8 @@
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts"
"./prefetch": "./src/prefetch.ts",
"./_open-webui/chat": "./src/open-webui/chat.tsx"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -28,7 +28,7 @@
right: 0;
bottom: var(--mantine-spacing-xs);
z-index: 3;
width: min(50vw, calc(100% - var(--mantine-spacing-md)));
width: calc(100% - var(--mantine-spacing-md));
margin-inline: auto;
box-shadow: var(--mantine-shadow-md);
}