From bf797441daa0adaa5d3fb4503d4385f7b3394c61 Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Sun, 25 Oct 2026 10:28:04 +0100 Subject: [PATCH 1/2] fix(security): stop sending channel credentials to organization members Storage and notification channel configs (S3 keys, SMTP passwords, webhook URLs) reached the browser of users who are not allowed to manage them (GHSA-xfcg-76xx-w539): - settings page: channels are only loaded for owners/admins - project page: policies no longer embed the full channel rows - database data action: channels are reduced to id/name/provider/enabled - backup list action: storage channel limited to id/name/provider - organization channel services: system-wide channels, and channels owned by another organization, are returned without their config --- .../(organization)/projects/[projectId]/page.tsx | 4 ++-- .../dashboard/(organization)/settings/page.tsx | 10 +++++++--- src/db/services/notification-channel.ts | 14 +++++++++----- src/db/services/storage-channel.ts | 14 +++++++++----- .../database/actions/backup-get-data.action.ts | 8 ++++++-- .../database/actions/backup-list.action.ts | 4 +++- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx index aa8bfa3e..792b37b9 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx @@ -54,8 +54,8 @@ export default async function RoutePage( ), with: { databases: true, - storagePolicies: { with: { storageChannel: true } }, - alertPolicies: { with: { notificationChannel: true } }, + storagePolicies: true, + alertPolicies: true, retentionPolicy: true, }, }); diff --git a/app/(customer)/dashboard/(organization)/settings/page.tsx b/app/(customer)/dashboard/(organization)/settings/page.tsx index 522af6ad..72c14664 100644 --- a/app/(customer)/dashboard/(organization)/settings/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/page.tsx @@ -35,10 +35,14 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) { notFound(); } - const notificationChannels = await getOrganizationChannels(organization.id); - const storageChannels = await getOrganizationStorageChannels(organization.id); - const agents = await getOrganizationAgents(organization.id); const permissions = computeOrganizationPermissions(activeMember); + const notificationChannels = permissions.canManageNotifications + ? await getOrganizationChannels(organization.id) + : []; + const storageChannels = permissions.canManageStorages + ? await getOrganizationStorageChannels(organization.id) + : []; + const agents = await getOrganizationAgents(organization.id); const users = await db.query.user.findMany({ where: (fields) => isNull(fields.deletedAt), diff --git a/src/db/services/notification-channel.ts b/src/db/services/notification-channel.ts index 47b5677d..c8823e82 100644 --- a/src/db/services/notification-channel.ts +++ b/src/db/services/notification-channel.ts @@ -54,9 +54,13 @@ export async function getOrganizationChannels( ]); const seen = new Set(); - return [...orgChannels, ...systemChannels].filter((c) => { - if (seen.has(c.id)) return false; - seen.add(c.id); - return true; - }) as NotificationChannel[]; + return [...orgChannels, ...systemChannels] + .filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) + .map((c) => + c.organizationId === organizationId ? c : { ...c, config: null }, + ) as NotificationChannel[]; } diff --git a/src/db/services/storage-channel.ts b/src/db/services/storage-channel.ts index 361c955d..afa92ea9 100644 --- a/src/db/services/storage-channel.ts +++ b/src/db/services/storage-channel.ts @@ -49,9 +49,13 @@ export async function getOrganizationStorageChannels( ]); const seen = new Set(); - return [...orgChannels, ...systemChannels].filter((c) => { - if (seen.has(c.id)) return false; - seen.add(c.id); - return true; - }) as StorageChannel[]; + return [...orgChannels, ...systemChannels] + .filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) + .map((c) => + c.organizationId === organizationId ? c : { ...c, config: null }, + ) as StorageChannel[]; } diff --git a/src/features/database/actions/backup-get-data.action.ts b/src/features/database/actions/backup-get-data.action.ts index e82eb98c..96ab50f6 100644 --- a/src/features/database/actions/backup-get-data.action.ts +++ b/src/features/database/actions/backup-get-data.action.ts @@ -9,6 +9,10 @@ import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import {getHealthLast12hLogs} from "@/db/services/healthcheck"; import {assertCanReadDatabase} from "@/features/database/utils/database-acl"; +type ChannelRow = { id: string; name: string; provider: string; enabled: boolean }; + +const toClientChannel = ({id, name, provider, enabled}: ChannelRow) => ({id, name, provider, enabled}); + export const getDatabaseDataAction = userAction .inputSchema( z.object({ @@ -88,10 +92,10 @@ export const getDatabaseDataAction = userAction if (database?.project?.organizationId) { const organizationChannels = await getOrganizationChannels(database.project.organizationId); - activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled); + activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled).map(toClientChannel); const organizationStorageChannels = await getOrganizationStorageChannels(database.project.organizationId); - activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled); + activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled).map(toClientChannel); } return { diff --git a/src/features/database/actions/backup-list.action.ts b/src/features/database/actions/backup-list.action.ts index 0d130990..6d529ea0 100644 --- a/src/features/database/actions/backup-list.action.ts +++ b/src/features/database/actions/backup-list.action.ts @@ -44,7 +44,9 @@ export const fetchBackupsAction = userAction restorations: true, storages: { with: { - storageChannel: true, + storageChannel: { + columns: { id: true, name: true, provider: true }, + }, }, }, }, From ae109e0ffab4af6410e85c9b0b717ea64ba1b5ac Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Sun, 25 Oct 2026 10:28:42 +0100 Subject: [PATCH 2/2] fix(database): mask database password in polled database data The database page masks credentials on first render, but the getDatabaseDataAction poll (every 4s) returned the raw config again. --- src/features/database/actions/backup-get-data.action.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/database/actions/backup-get-data.action.ts b/src/features/database/actions/backup-get-data.action.ts index 96ab50f6..b801d8ff 100644 --- a/src/features/database/actions/backup-get-data.action.ts +++ b/src/features/database/actions/backup-get-data.action.ts @@ -8,6 +8,7 @@ import {getOrganizationChannels} from "@/db/services/notification-channel"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import {getHealthLast12hLogs} from "@/db/services/healthcheck"; import {assertCanReadDatabase} from "@/features/database/utils/database-acl"; +import {maskDatabaseSecretsForClient} from "@/features/database/utils/credential-fields"; type ChannelRow = { id: string; name: string; provider: string; enabled: boolean }; @@ -99,7 +100,7 @@ export const getDatabaseDataAction = userAction } return { - database, + database: database && maskDatabaseSecretsForClient(database), // @ts-ignore activeOrganizationChannels, // @ts-ignore