Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/api/src/controllers/Events.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/services/EventService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/services/__tests__/EventService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand Down
Loading