diff --git a/apps/api/src/controllers/Events.ts b/apps/api/src/controllers/Events.ts index d37ef113..8c28b334 100644 --- a/apps/api/src/controllers/Events.ts +++ b/apps/api/src/controllers/Events.ts @@ -1,7 +1,10 @@ import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; import signale from 'signale'; +import {prisma} from '../database/prisma.js'; +import {NotFound} from '../exceptions/index.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; +import {ContactService} from '../services/ContactService.js'; import {EventService} from '../services/EventService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -22,6 +25,24 @@ export class Events { return res.status(400).json({error: 'Event name is required'}); } + // Both IDs are client-supplied: they must belong to the authenticated project + // before they are recorded or used to start workflows (cross-tenant IDOR) + if (contactId) { + // Throws a 404 when the contact belongs to another project + await ContactService.get(auth.projectId!, contactId); + } + + if (emailId) { + const email = await prisma.email.findFirst({ + where: {id: emailId, projectId: auth.projectId!}, + select: {id: true}, + }); + + if (!email) { + throw new NotFound('email', emailId); + } + } + const event = await EventService.trackEvent(auth.projectId!, name, contactId, emailId, data); return res.status(201).json(event); diff --git a/apps/api/src/services/EventService.ts b/apps/api/src/services/EventService.ts index af678fc7..0a06b15d 100644 --- a/apps/api/src/services/EventService.ts +++ b/apps/api/src/services/EventService.ts @@ -431,6 +431,20 @@ export class EventService { return; } + // Never run a workflow against a contact from another project. + // Queried directly instead of via ContactService, which imports this service. + const contact = await prisma.contact.findFirst({ + where: {id: contactId, projectId: workflow.projectId}, + select: {id: true}, + }); + + if (!contact) { + signale.warn( + `[EVENT] Refusing to start workflow ${workflowId} for contact ${contactId}: contact does not belong to project ${workflow.projectId}`, + ); + return; + } + // Check re-entry rules if (!workflow.allowReentry) { // If re-entry is not allowed, check if contact has ANY execution (regardless of status) diff --git a/apps/api/src/services/__tests__/EventService.test.ts b/apps/api/src/services/__tests__/EventService.test.ts index c5c245e2..d7e66bf8 100644 --- a/apps/api/src/services/__tests__/EventService.test.ts +++ b/apps/api/src/services/__tests__/EventService.test.ts @@ -158,6 +158,27 @@ describe('EventService', () => { expect([WorkflowExecutionStatus.WAITING, WorkflowExecutionStatus.COMPLETED]).toContain(executions[0].status); }); + it('should NOT start a workflow for a contact from another project', async () => { + const {project: otherProject} = await factories.createUserWithProject(); + const foreignContact = await factories.createContact({projectId: otherProject.id}); + + const workflow = await factories.createWorkflow({ + projectId, + enabled: true, + triggerType: WorkflowTriggerType.EVENT, + triggerConfig: {eventName: 'cross.tenant'}, + }); + + // Event tracked in this project, but pointing at another tenant's contact + await EventService.trackEvent(projectId, 'cross.tenant', foreignContact.id); + + const executions = await prisma.workflowExecution.findMany({ + where: {workflowId: workflow.id}, + }); + + expect(executions).toHaveLength(0); + }); + it('should NOT trigger disabled workflows', async () => { const contact = await factories.createContact({projectId});