From 9d024a07f74d2fd6c206dd763c36a4d0514f8719 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 17 Sep 2026 13:20:33 +0300 Subject: [PATCH] fix: enforce document visibility on envelope file routes --- apps/remix/server/api/download/download.ts | 30 ++++++++++++-- apps/remix/server/api/files/files.helpers.ts | 39 +++++++++++++++---- apps/remix/server/api/files/files.ts | 13 +++---- .../api/files/routes/get-envelope-item-pdf.ts | 5 ++- 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/apps/remix/server/api/download/download.ts b/apps/remix/server/api/download/download.ts index 6682c327c..ff2f3c41c 100644 --- a/apps/remix/server/api/download/download.ts +++ b/apps/remix/server/api/download/download.ts @@ -5,7 +5,6 @@ import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-aud import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; -import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { sValidator } from '@hono/standard-validator'; import { DocumentStatus, EnvelopeType } from '@prisma/client'; @@ -75,12 +74,35 @@ export const downloadRoute = new Hono() version, }); + const envelopeItemReference = await prisma.envelopeItem.findUnique({ + where: { + id: envelopeItemId, + }, + select: { + envelopeId: true, + }, + }); + + if (!envelopeItemReference) { + return c.json({ error: 'Envelope item not found' }, 404); + } + + // Apply the same owner / team-role visibility / team-email rules as every + // other envelope read path, rather than bare team membership. + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: envelopeItemReference.envelopeId, + }, + type: null, + userId: apiToken.user.id, + teamId: apiToken.teamId, + }); + const envelopeItem = await prisma.envelopeItem.findFirst({ where: { id: envelopeItemId, - envelope: { - team: buildTeamWhereQuery({ teamId: apiToken.teamId, userId: apiToken.user.id }), - }, + envelope: envelopeWhereInput, }, include: { envelope: { diff --git a/apps/remix/server/api/files/files.helpers.ts b/apps/remix/server/api/files/files.helpers.ts index 3b0dc1f05..e42f756ae 100644 --- a/apps/remix/server/api/files/files.helpers.ts +++ b/apps/remix/server/api/files/files.helpers.ts @@ -1,18 +1,21 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf'; -import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { sha256 } from '@documenso/lib/universal/crypto'; import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; +import { canAccessTeamDocument } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { type DocumentDataType, DocumentStatus, + type DocumentVisibility, type EnvelopeType, EnvelopeType as EnvelopeTypeEnum, type RecipientRole, type SigningStatus, + TeamMemberRole, type TemplateType, TemplateType as TemplateTypeEnum, } from '@prisma/client'; @@ -262,30 +265,51 @@ const handlePendingFileRequest = async ({ type CheckEnvelopeFileAccessOptions = { userId: number; teamId: number; + envelopeId: string; envelopeType: EnvelopeType; templateType: TemplateType; + visibility: DocumentVisibility; }; /** * Check whether a user has access to an envelope's file. * - * First checks team membership. If that fails and the envelope is an - * ORGANISATION template (not a document), falls back to checking whether - * the user belongs to any team in the same organisation. + * Mirrors the tRPC read paths (owner / team role permitting `visibility` / team + * email). If that fails and the envelope is an ORGANISATION template (not a + * document), falls back to checking whether the user belongs to any team in the + * same organisation with a role that permits the template's `visibility`. */ export const checkEnvelopeFileAccess = async ({ userId, teamId, + envelopeId, envelopeType, templateType, + visibility, }: CheckEnvelopeFileAccessOptions): Promise => { - const team = await getTeamById({ userId, teamId }).catch(() => null); + try { + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + type: envelopeType, + userId, + teamId, + }); - if (team) { - return true; + const envelope = await prisma.envelope.findFirst({ where: envelopeWhereInput, select: { id: true } }); + + if (envelope) { + return true; + } + } catch (error) { + // NOT_FOUND means the user is not a member of the envelope's team. Anything else is a real failure. + if (!(error instanceof AppError && error.code === AppErrorCode.NOT_FOUND)) { + throw error; + } } if (envelopeType === EnvelopeTypeEnum.TEMPLATE && templateType === TemplateTypeEnum.ORGANISATION) { + const rolesWithAccess = Object.values(TeamMemberRole).filter((role) => canAccessTeamDocument(role, visibility)); + const orgAccess = await prisma.team.findFirst({ where: { id: teamId, @@ -301,6 +325,7 @@ export const checkEnvelopeFileAccess = async ({ }, }, }, + teamRole: { in: rolesWithAccess }, }, }, }, diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index bbca38885..9a8d9feb9 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -110,12 +110,14 @@ export const filesRoute = new Hono() const hasAccess = await checkEnvelopeFileAccess({ userId, teamId: envelope.teamId, + envelopeId, envelopeType: envelope.type, templateType: envelope.templateType, + visibility: envelope.visibility, }); if (!hasAccess) { - return c.json({ error: 'User does not have access to the team that this envelope is associated with' }, 403); + return c.json({ error: 'User does not have access to this envelope' }, 403); } if (!envelopeItem.documentData) { @@ -182,17 +184,14 @@ export const filesRoute = new Hono() const hasDownloadAccess = await checkEnvelopeFileAccess({ userId: session.user.id, teamId: envelope.teamId, + envelopeId, envelopeType: envelope.type, templateType: envelope.templateType, + visibility: envelope.visibility, }); if (!hasDownloadAccess) { - return c.json( - { - error: 'User does not have access to the team that this envelope is associated with', - }, - 403, - ); + return c.json({ error: 'User does not have access to this envelope' }, 403); } if (!envelopeItem.documentData) { diff --git a/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts index 425717121..98f9229b4 100644 --- a/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts +++ b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts @@ -54,7 +54,7 @@ route.get( return c.json({ error: 'Not found' }, 404); } - // Note: We authenticate whether the user can access this in the `getTeamById` below. + // Note: We authorize whether the user can access this in `checkEnvelopeFileAccess` below. const envelopeItem = await prisma.envelopeItem.findFirst({ where: { id: envelopeItemId, @@ -69,6 +69,7 @@ route.get( type: true, teamId: true, templateType: true, + visibility: true, }, }, }, @@ -82,8 +83,10 @@ route.get( const hasAccess = await checkEnvelopeFileAccess({ userId, teamId: envelopeItem.envelope.teamId, + envelopeId, envelopeType: envelopeItem.envelope.type, templateType: envelopeItem.envelope.templateType, + visibility: envelopeItem.envelope.visibility, }); if (!hasAccess) {