diff --git a/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx b/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx index e472bb5c9..0ab921a59 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx @@ -1,3 +1,4 @@ +import { toSafeHref } from '@documenso/lib/utils/is-http-url'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; @@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({ {attachments?.data.map((attachment) => ( ; @@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({

{attachment.label}

; @@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({

{attachment.label}

{ + try { + await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput })); + + return true; + } catch { + return false; + } +}; + +test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => { + const { user: owner, team } = await seedUser(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + status: DocumentStatus.DRAFT, + }, + }); + + // The member cannot read an ADMIN-visibility document, so they must not be + // able to delete it either. + expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false); + + await expect( + deleteDocument({ + id: { type: 'envelopeId', id: envelope.id }, + userId: member.id, + teamId: team.id, + requestMetadata, + }), + ).rejects.toThrow(); + + const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } }); + expect(stillExists).not.toBeNull(); +}); + +test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => { + const { user: owner, team } = await seedUser(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + status: DocumentStatus.PENDING, + }, + }); + + // A manager outranks a member but still cannot read an ADMIN-visibility + // document, so cancellation must be blocked despite the sufficient role. + expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false); + + await expect( + cancelDocument({ + id: { type: 'envelopeId', id: envelope.id }, + userId: manager.id, + teamId: team.id, + reason: 'test-cancel', + requestMetadata, + }), + ).rejects.toThrow(); + + const after = await prisma.envelope.findUnique({ where: { id: envelope.id } }); + expect(after?.status).toBe(DocumentStatus.PENDING); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts new file mode 100644 index 000000000..c3228a057 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts @@ -0,0 +1,70 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +/** + * Attachment URLs are rendered as link hrefs, so they must be restricted to + * http(s). The API must reject any other scheme. + */ +const NON_HTTP_URLS = [ + 'javascript:alert(document.cookie)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', +]; + +test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id); + + for (const url of NON_HTTP_URLS) { + const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } }, + }); + + expect(res.ok(), `expected ${url} to be rejected`).toBe(false); + } + + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(0); +}); + +test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id); + + const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } }, + }); + + expect(res.ok()).toBe(true); + + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(1); + expect(attachments[0].data).toBe('https://example.com/file.pdf'); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts new file mode 100644 index 000000000..f47a76c1f --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts @@ -0,0 +1,121 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { type APIRequestContext, expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => { + const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return res.ok(); +}; + +/** + * Attachment create/update/delete/list must enforce document visibility, not + * just team membership. A member whose visibility tier excludes a restricted + * envelope must not be able to read or mutate its attachments. + */ +test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } }, + }); + + expect(createRes.ok()).toBe(false); + + // No attachment should have been created. + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(0); +}); + +test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + // The owner (who can see the document) creates the attachment. + const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } }, + }); + expect(createRes.ok()).toBe(true); + const attachment = await createRes.json(); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, { + headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' }, + data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } }, + }); + + expect(updateRes.ok()).toBe(false); + + const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } }); + expect(persisted?.label).toBe('original'); +}); + +test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } }, + }); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(findRes.ok()).toBe(false); + + const body = findRes.ok() ? await findRes.json() : null; + const attachments = body?.data ?? []; + expect(attachments).toHaveLength(0); +}); diff --git a/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts index b688c7cd9..23483a48e 100644 --- a/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts @@ -340,3 +340,67 @@ test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () expect(deleted).toBeNull(); }); }); + +test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => { + test('cannot add a member from another organisation to a group', async ({ page }) => { + // Organisation A, where the actor is the owner/admin. + const { user: actor, organisation: organisationA } = await seedUser({ + isPersonalOrganisation: false, + }); + + // A separate organisation B with a member the actor has no authority over. + const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false }); + const [foreignUser] = await seedOrganisationMembers({ + members: [{ name: 'Foreign', organisationRole: 'MEMBER' }], + organisationId: organisationB.id, + }); + + const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id); + + // A custom group the actor legitimately controls in organisation A. + const groupA = await createCustomGroup(organisationA.id, 'MEMBER'); + + await apiSignin({ page, email: actor.email }); + + const res = await trpcMutation(page, 'organisation.group.update', { + id: groupA.id, + memberIds: [foreignMember.id], + }); + + expect(res.ok()).toBeFalsy(); + + const injectedMembership = await prisma.organisationGroupMember.findFirst({ + where: { groupId: groupA.id, organisationMemberId: foreignMember.id }, + }); + + expect(injectedMembership).toBeNull(); + }); + + test('can add a member from the same organisation to a group (positive control)', async ({ page }) => { + const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [memberUser] = await seedOrganisationMembers({ + members: [{ name: 'Member', organisationRole: 'MEMBER' }], + organisationId: organisation.id, + }); + + const member = await getOrganisationMember(memberUser.id, organisation.id); + + const group = await createCustomGroup(organisation.id, 'MEMBER'); + + await apiSignin({ page, email: actor.email }); + + const res = await trpcMutation(page, 'organisation.group.update', { + id: group.id, + memberIds: [member.id], + }); + + expect(res.ok()).toBeTruthy(); + + const membership = await prisma.organisationGroupMember.findFirst({ + where: { groupId: group.id, organisationMemberId: member.id }, + }); + + expect(membership).not.toBeNull(); + }); +}); diff --git a/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts b/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts new file mode 100644 index 000000000..50de44307 --- /dev/null +++ b/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts @@ -0,0 +1,72 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedCompletedDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +/** + * Reading a recipient exposes its signing token (a bearer credential), so the + * recipient read must enforce document visibility — a member who cannot read a + * restricted document must not be able to read its recipients either. This + * mirrors the field read, which is asserted as a control below. + */ +test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } }); + + const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(res.status()).toBe(404); + + const body = res.ok() ? await res.json() : null; + expect(body?.token).toBeUndefined(); +}); + +test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const field = await prisma.field.findFirst({ where: { envelopeId: document.id } }); + + test.skip(!field, 'No field seeded on completed document'); + + const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(res.status()).toBe(404); +}); diff --git a/packages/app-tests/e2e/teams/team-profile-access.spec.ts b/packages/app-tests/e2e/teams/team-profile-access.spec.ts new file mode 100644 index 000000000..ecff18fed --- /dev/null +++ b/packages/app-tests/e2e/teams/team-profile-access.spec.ts @@ -0,0 +1,53 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +/** + * Editing the team public profile is a team-management action and must require + * MANAGE_TEAM, consistent with renaming the team or changing its URL. + */ +test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await apiSignin({ page, email: member.email }); + + const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, { + headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() }, + data: JSON.stringify({ + json: { + teamId: team.id, + data: { profileEnabled: true, profileBio: 'edited-by-member' }, + }, + }), + }); + + expect(profileRes.status()).not.toBe(200); + + const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } }); + expect(profile?.enabled ?? false).toBe(false); + expect(profile?.bio ?? '').not.toBe('edited-by-member'); + + // The name/url path of the same route is also management-gated. + const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, { + headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() }, + data: JSON.stringify({ + json: { teamId: team.id, data: { name: 'renamed-by-member' } }, + }), + }); + + expect(nameRes.status()).not.toBe(200); + + const reloaded = await prisma.team.findUnique({ where: { id: team.id } }); + expect(reloaded?.name).not.toBe('renamed-by-member'); + + expect(owner.id).toBeTruthy(); +}); diff --git a/packages/lib/server-only/document/cancel-document.ts b/packages/lib/server-only/document/cancel-document.ts index 3ac41ca6e..6496e0908 100644 --- a/packages/lib/server-only/document/cancel-document.ts +++ b/packages/lib/server-only/document/cancel-document.ts @@ -8,8 +8,9 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../ import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import type { EnvelopeIdOptions } from '../../utils/envelope'; -import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; +import { mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { isMemberManagerOrAbove } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getMemberRoles } from '../team/get-member-roles'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -22,9 +23,18 @@ export type CancelDocumentOptions = { }; export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => { - // Note: This is an unsafe request, we validate the ownership/permission later in the function. - const envelope = await prisma.envelope.findUnique({ - where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), + // Resolve the envelope through the visibility-aware helper so the caller must + // have read access (ownership OR team membership with sufficient visibility OR + // team-email). This prevents cancelling a document the caller cannot see. + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id, + userId, + teamId, + type: EnvelopeType.DOCUMENT, + }); + + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, include: { recipients: true, documentMeta: true, @@ -49,16 +59,6 @@ export const cancelDocument = async ({ id, userId, teamId, reason, requestMetada .then((roles) => roles.teamRole) .catch(() => null); - const isUserTeamMember = teamRole !== null; - - // Callers with no relationship to the document must not be able to determine - // whether it exists, so respond as if it was not found. - if (!isUserOwner && !isUserTeamMember) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Document not found', - }); - } - const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole); // The document is visible to the caller, but cancelling requires elevated permissions. diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 8eed2702f..0a4456d5c 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -13,7 +13,7 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { isRecipientEmailValidForSending } from '../../utils/recipients'; import { getEmailContext } from '../email/get-email-context'; -import { getMemberRoles } from '../team/get-member-roles'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type DeleteDocumentOptions = { @@ -36,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De }); } - // Note: This is an unsafe request, we validate the ownership later in the function. + // Note: This is an unsafe request. It is used purely to resolve the recipient + // self-hide path below. The authoritative delete authorization is performed + // via the visibility-aware `getEnvelopeWhereInput` helper. const envelope = await prisma.envelope.findUnique({ where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), include: { @@ -51,27 +53,36 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De }); } - const isUserTeamMember = await getMemberRoles({ - teamId: envelope.teamId, - reference: { - type: 'User', - id: userId, - }, + // Determine whether the user has authorized delete access using the + // visibility-aware helper. This enforces ownership OR (team membership AND + // the document's visibility is permitted for the member's role) OR team-email + // access. A bare team member without sufficient visibility will resolve to + // null here and therefore must not be able to delete the document. + const hasDeleteAccess = await getEnvelopeWhereInput({ + id, + userId, + teamId, + type: EnvelopeType.DOCUMENT, }) - .then(() => true) + .then(({ envelopeWhereInput }) => + prisma.envelope.findFirst({ + where: envelopeWhereInput, + select: { id: true }, + }), + ) + .then((result) => Boolean(result)) .catch(() => false); - const isUserOwner = envelope.userId === userId; const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email); - if (!isUserOwner && !isUserTeamMember && !userRecipient) { + if (!hasDeleteAccess && !userRecipient) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Not allowed', }); } // Handle hard or soft deleting the actual document if user has permission. - if (isUserOwner || isUserTeamMember) { + if (hasDeleteAccess) { await handleDocumentOwnerDelete({ envelope, user, diff --git a/packages/lib/server-only/envelope-attachment/create-attachment.ts b/packages/lib/server-only/envelope-attachment/create-attachment.ts index b753378cf..0e16639f0 100644 --- a/packages/lib/server-only/envelope-attachment/create-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/create-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type CreateAttachmentOptions = { envelopeId: string; @@ -15,11 +15,15 @@ export type CreateAttachmentOptions = { }; export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => { + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }); + const envelope = await prisma.envelope.findFirst({ - where: { - id: envelopeId, - team: buildTeamWhereQuery({ teamId, userId }), - }, + where: envelopeWhereInput, }); if (!envelope) { diff --git a/packages/lib/server-only/envelope-attachment/delete-attachment.ts b/packages/lib/server-only/envelope-attachment/delete-attachment.ts index 67f4e7974..f04e6cbb9 100644 --- a/packages/lib/server-only/envelope-attachment/delete-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/delete-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type DeleteAttachmentOptions = { id: string; @@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO const attachment = await prisma.envelopeAttachment.findFirst({ where: { id, - envelope: { - team: buildTeamWhereQuery({ teamId, userId }), - }, }, include: { envelope: true, @@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: attachment.envelopeId }, + userId, + teamId, + type: null, + }); + + // Additional validation to check the user has visibility-aware access to the envelope. + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Attachment not found', + }); + } + if ( attachment.envelope.status === DocumentStatus.COMPLETED || attachment.envelope.status === DocumentStatus.REJECTED diff --git a/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts b/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts index ff6402fea..1e8b1d25e 100644 --- a/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts +++ b/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts @@ -1,7 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type FindAttachmentsByEnvelopeIdOptions = { envelopeId: string; @@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({ userId, teamId, }: FindAttachmentsByEnvelopeIdOptions) => { + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }); + const envelope = await prisma.envelope.findFirst({ - where: { - id: envelopeId, - team: buildTeamWhereQuery({ teamId, userId }), - }, + where: envelopeWhereInput, }); if (!envelope) { diff --git a/packages/lib/server-only/envelope-attachment/update-attachment.ts b/packages/lib/server-only/envelope-attachment/update-attachment.ts index c07e902a9..1758c2b60 100644 --- a/packages/lib/server-only/envelope-attachment/update-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/update-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type UpdateAttachmentOptions = { id: string; @@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac const attachment = await prisma.envelopeAttachment.findFirst({ where: { id, - envelope: { - team: buildTeamWhereQuery({ teamId, userId }), - }, }, include: { envelope: true, @@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: attachment.envelopeId }, + userId, + teamId, + type: null, + }); + + // Additional validation to check the user has visibility-aware access to the envelope. + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Attachment not found', + }); + } + if ( attachment.envelope.status === DocumentStatus.COMPLETED || attachment.envelope.status === DocumentStatus.REJECTED diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts deleted file mode 100644 index 887730b5c..000000000 --- a/packages/lib/server-only/public-api/get-user-by-token.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -import { AppError, AppErrorCode } from '../../errors/app-error'; -import { hashString } from '../auth/hash'; - -export const getUserByApiToken = async ({ token }: { token: string }) => { - const hashedToken = hashString(token); - - const user = await prisma.user.findFirst({ - where: { - apiTokens: { - some: { - token: hashedToken, - }, - }, - }, - include: { - apiTokens: true, - }, - }); - - if (!user) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'Invalid token', - statusCode: 401, - }); - } - - const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken); - - // This should be impossible but we need to satisfy TypeScript - if (!retrievedToken) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'Invalid token', - statusCode: 401, - }); - } - - if (retrievedToken.expires && retrievedToken.expires < new Date()) { - throw new Error('Expired token'); - } - - return user; -}; diff --git a/packages/lib/server-only/recipient/get-recipient-by-id.ts b/packages/lib/server-only/recipient/get-recipient-by-id.ts index 1f0a48855..0a8df960b 100644 --- a/packages/lib/server-only/recipient/get-recipient-by-id.ts +++ b/packages/lib/server-only/recipient/get-recipient-by-id.ts @@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type GetRecipientByIdOptions = { recipientId: number; @@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: recipient.envelopeId, + }, + type, + userId, + teamId, + }); + + // Additional validation to check visibility. + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + const legacyId = { documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null, templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null, diff --git a/packages/lib/server-only/share/create-or-get-share-link.ts b/packages/lib/server-only/share/create-or-get-share-link.ts index 64842edcb..84917d74b 100644 --- a/packages/lib/server-only/share/create-or-get-share-link.ts +++ b/packages/lib/server-only/share/create-or-get-share-link.ts @@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { alphaid } from '../../universal/id'; import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type CreateSharingIdOptions = | { @@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha ), select: { id: true, + teamId: true, }, }); @@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha .then((recipient) => recipient?.email); }) .with({ userId: P.number }, async ({ userId }) => { + // Ensure the authenticated user actually has visibility-aware access to the + // envelope before allowing them to create a share link. The share route does + // not carry a teamId, so we derive it from the envelope and reuse the canonical + // visibility check (owner OR team member with sufficient visibility). + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'documentId', + id: documentId, + }, + userId, + teamId: envelope.teamId, + type: EnvelopeType.DOCUMENT, + }); + + const accessibleEnvelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + select: { + id: true, + }, + }); + + if (!accessibleEnvelope) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + return await prisma.user .findFirst({ where: { diff --git a/packages/lib/server-only/team/get-team-public-profile.ts b/packages/lib/server-only/team/get-team-public-profile.ts index 1f0b289d0..99e46ced2 100644 --- a/packages/lib/server-only/team/get-team-public-profile.ts +++ b/packages/lib/server-only/team/get-team-public-profile.ts @@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { buildTeamWhereQuery } from '../../utils/teams'; -import { updateTeamPublicProfile } from './update-team-public-profile'; export type GetTeamPublicProfileOptions = { userId: number; @@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({ }); } - // Create and return the public profile. + // Lazily initialize a disabled public profile on first access. Membership is + // already verified by the query above, so this system initialization does not + // impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes. if (!team.profile) { - const { url, profile } = await updateTeamPublicProfile({ - userId: userId, - teamId, - data: { + const profile = await prisma.teamProfile.upsert({ + where: { + teamId, + }, + create: { + teamId, enabled: false, }, + update: {}, }); - if (!profile) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Failed to create public profile', - }); - } - return { profile, - url, + url: team.url, }; } diff --git a/packages/lib/server-only/team/update-team-public-profile.ts b/packages/lib/server-only/team/update-team-public-profile.ts index ca78bd008..c39e31dbc 100644 --- a/packages/lib/server-only/team/update-team-public-profile.ts +++ b/packages/lib/server-only/team/update-team-public-profile.ts @@ -1,3 +1,4 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; import { prisma } from '@documenso/prisma'; import { buildTeamWhereQuery } from '../../utils/teams'; @@ -13,7 +14,11 @@ export type UpdatePublicProfileOptions = { export const updateTeamPublicProfile = async ({ userId, teamId, data }: UpdatePublicProfileOptions) => { return await prisma.team.update({ - where: buildTeamWhereQuery({ teamId, userId }), + where: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), data: { profile: { upsert: { diff --git a/packages/lib/server-only/user/forgot-password.ts b/packages/lib/server-only/user/forgot-password.ts index 7197f50c7..86d1c1aaa 100644 --- a/packages/lib/server-only/user/forgot-password.ts +++ b/packages/lib/server-only/user/forgot-password.ts @@ -18,31 +18,26 @@ export const forgotPassword = async ({ email }: { email: string }) => { return; } - // Find a token that was created in the last hour and hasn't expired - // const existingToken = await prisma.passwordResetToken.findFirst({ - // where: { - // userId: user.id, - // expiry: { - // gt: new Date(), - // }, - // createdAt: { - // gt: new Date(Date.now() - ONE_HOUR), - // }, - // }, - // }); - - // if (existingToken) { - // return; - // } - const token = crypto.randomBytes(18).toString('hex'); - await prisma.passwordResetToken.create({ - data: { - token, - expiry: new Date(Date.now() + ONE_HOUR), - userId: user.id, - }, + // Invalidate any prior reset tokens for this user before issuing a new one, so + // only a single token is ever live at a time. We still always issue a fresh + // token (and email) so the user can request a new link if a prior email never + // arrived, while bounding the number of usable tokens to one. + await prisma.$transaction(async (tx) => { + await tx.passwordResetToken.deleteMany({ + where: { + userId: user.id, + }, + }); + + await tx.passwordResetToken.create({ + data: { + token, + expiry: new Date(Date.now() + ONE_HOUR), + userId: user.id, + }, + }); }); await sendForgotPassword({ diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts index 370995dd8..c05c99250 100644 --- a/packages/lib/server-only/webhooks/zapier/subscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts @@ -1,4 +1,6 @@ -import { AppError } from '@documenso/lib/errors/app-error'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { assertNotPrivateUrl } from '../assert-webhook-url'; @@ -9,14 +11,36 @@ export const subscribeHandler = async (req: Request) => { const authorization = req.headers.get('authorization'); if (!authorization) { - return new Response('Unauthorized', { status: 401 }); + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); } const { webhookUrl, eventTrigger } = await req.json(); await assertNotPrivateUrl(webhookUrl); - const result = await validateApiToken({ authorization }); + const result = await validateApiToken({ authorization }).catch(() => { + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); + }); + + const userId = result.userId ?? result.user.id; + const teamId = result.teamId ?? undefined; + + // Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the + // tRPC webhook mutations (create-webhook.ts). Guards against stale-privilege + // use of a token minted while the holder was privileged. + const team = await prisma.team.findFirst({ + where: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), + }); + + if (!team) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to manage webhooks for this team', + }); + } const createdWebhook = await prisma.webhook.create({ data: { @@ -24,15 +48,19 @@ export const subscribeHandler = async (req: Request) => { eventTriggers: [eventTrigger], secret: null, enabled: true, - userId: result.userId ?? result.user.id, - teamId: result.teamId ?? undefined, + userId, + teamId, }, }); return Response.json(createdWebhook); } catch (err) { if (err instanceof AppError) { - return Response.json({ message: err.message }, { status: 400 }); + // Map authorization failures to 401, keep other AppErrors as 400 to + // preserve the existing Zapier contract (e.g. invalid webhook URL). + const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400; + + return Response.json({ message: err.message }, { status }); } console.error(err); diff --git a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts index 4772c164b..df21c0b65 100644 --- a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts @@ -1,3 +1,6 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { validateApiToken } from './validateApiToken'; @@ -7,23 +10,42 @@ export const unsubscribeHandler = async (req: Request) => { const authorization = req.headers.get('authorization'); if (!authorization) { - return new Response('Unauthorized', { status: 401 }); + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); } const { webhookId } = await req.json(); - const result = await validateApiToken({ authorization }); + const result = await validateApiToken({ authorization }).catch(() => { + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); + }); + const userId = result.userId ?? result.user.id; + const teamId = result.teamId ?? undefined; + + // Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the + // tRPC delete-webhook-by-id mutation. Guards against stale-privilege use of a + // token minted while the holder was privileged. const deletedWebhook = await prisma.webhook.delete({ where: { id: webhookId, - userId: result.userId ?? result.user.id, - teamId: result.teamId ?? undefined, + team: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), }, }); return Response.json(deletedWebhook); } catch (err) { + if (err instanceof AppError) { + // Map authorization failures to 401, keep other AppErrors as 400 to + // preserve the existing Zapier contract. + const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400; + + return Response.json({ message: err.message }, { status }); + } + console.error(err); return Response.json( diff --git a/packages/lib/utils/is-http-url.test.ts b/packages/lib/utils/is-http-url.test.ts new file mode 100644 index 000000000..3454f32eb --- /dev/null +++ b/packages/lib/utils/is-http-url.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { isHttpUrl, toSafeHref } from './is-http-url'; + +describe('isHttpUrl', () => { + it('accepts http and https URLs', () => { + expect(isHttpUrl('http://example.com')).toBe(true); + expect(isHttpUrl('https://example.com/path?q=1#hash')).toBe(true); + expect(isHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true); + }); + + it('rejects non-http(s) schemes', () => { + expect(isHttpUrl('javascript:alert(1)')).toBe(false); + expect(isHttpUrl('JavaScript:alert(1)')).toBe(false); + expect(isHttpUrl('data:text/html,')).toBe(false); + expect(isHttpUrl('vbscript:msgbox(1)')).toBe(false); + expect(isHttpUrl('file:///etc/passwd')).toBe(false); + }); + + it('rejects non-absolute or unparseable values', () => { + expect(isHttpUrl('not a url')).toBe(false); + expect(isHttpUrl('/relative/path')).toBe(false); + expect(isHttpUrl('')).toBe(false); + }); + + it('does not treat leading whitespace tricks as safe', () => { + // `new URL` trims leading control chars; ensure a smuggled scheme is rejected. + expect(isHttpUrl(' javascript:alert(1)')).toBe(false); + expect(isHttpUrl('java\tscript:alert(1)')).toBe(false); + }); +}); + +describe('toSafeHref', () => { + it('returns the URL when it is http(s)', () => { + expect(toSafeHref('https://example.com')).toBe('https://example.com'); + }); + + it('returns undefined for dangerous or empty values', () => { + expect(toSafeHref('javascript:alert(1)')).toBeUndefined(); + expect(toSafeHref('data:text/html,x')).toBeUndefined(); + expect(toSafeHref('')).toBeUndefined(); + expect(toSafeHref(null)).toBeUndefined(); + expect(toSafeHref(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/lib/utils/is-http-url.ts b/packages/lib/utils/is-http-url.ts new file mode 100644 index 000000000..e5727349b --- /dev/null +++ b/packages/lib/utils/is-http-url.ts @@ -0,0 +1,32 @@ +const ALLOWED_PROTOCOLS = ['http', 'https']; + +/** + * Returns true only when `value` parses as an absolute URL using the http or + * https protocol. + * + * Zod's `.url()` accepts any parseable URL, including non-web schemes. Use this + * to restrict user-supplied URLs to http(s) before they are stored or rendered + * as a link. + */ +export const isHttpUrl = (value: string) => { + try { + const url = new URL(value); + + return ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase()); + } catch { + return false; + } +}; + +/** + * Returns the value to use for a link `href` only when it is an http(s) URL, + * otherwise `undefined`. Use this when rendering user-supplied URLs as anchors, + * including for older rows stored before URL validation was in place. + */ +export const toSafeHref = (value: string | null | undefined): string | undefined => { + if (!value || !isHttpUrl(value)) { + return undefined; + } + + return value; +}; diff --git a/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts b/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts index f07be361d..5a8484381 100644 --- a/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts +++ b/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts @@ -1,3 +1,4 @@ +import { isHttpUrl } from '@documenso/lib/utils/is-http-url'; import { z } from 'zod'; import type { TrpcRouteMeta } from '../../trpc'; @@ -16,7 +17,7 @@ export const ZCreateAttachmentRequestSchema = z.object({ envelopeId: z.string(), data: z.object({ label: z.string().min(1, 'Label is required'), - data: z.string().url('Must be a valid URL'), + data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'), }), }); diff --git a/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts b/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts index 2acff8257..2f60e1fc2 100644 --- a/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts +++ b/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts @@ -1,3 +1,4 @@ +import { isHttpUrl } from '@documenso/lib/utils/is-http-url'; import { z } from 'zod'; import { ZSuccessResponseSchema } from '../../schema'; @@ -17,7 +18,7 @@ export const ZUpdateAttachmentRequestSchema = z.object({ id: z.string(), data: z.object({ label: z.string().min(1, 'Label is required'), - data: z.string().url('Must be a valid URL'), + data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'), }), }); diff --git a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts index 7ef3e8bc6..4372b9291 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts @@ -1,4 +1,5 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; @@ -41,5 +42,26 @@ export const getEnvelopeRecipientRoute = authenticatedProcedure }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: recipient.envelopeId, + }, + type: null, + userId: user.id, + teamId, + }); + + // Additional validation to check visibility. + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + return recipient; }); diff --git a/packages/trpc/server/organisation-router/update-organisation-group.ts b/packages/trpc/server/organisation-router/update-organisation-group.ts index 06d45d763..c5be77bd2 100644 --- a/packages/trpc/server/organisation-router/update-organisation-group.ts +++ b/packages/trpc/server/organisation-router/update-organisation-group.ts @@ -38,6 +38,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure }, include: { organisationGroupMembers: true, + organisation: { + include: { + members: { + select: { + id: true, + }, + }, + }, + }, }, }); @@ -78,6 +87,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure const groupMemberIds = unique(data.memberIds || []); + // Validate that members belong to the same organisation as the group. + groupMemberIds.forEach((memberId) => { + const member = organisationGroup.organisation.members.find(({ id }) => id === memberId); + + if (!member) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + }); + const membersToDelete = organisationGroup.organisationGroupMembers.filter( (member) => !groupMemberIds.includes(member.organisationMemberId), );