Skip to content

Security: Cross-Tenant Authorization Bypass Allows Campaigns to Reference Another Workspace's Email Service #339

Description

@superman-04

Summary

A cross-tenant authorization bypass exists in campaign creation and dispatch workflows. The application validates only that the supplied email_service_id exists in the database and does not verify ownership of the referenced resource.

As a result, a user in Workspace B can create a campaign referencing an EmailService belonging to Workspace A. During campaign dispatch, the application resolves and operates on the foreign tenant's EmailService object without performing any workspace ownership validation.

This breaks tenant isolation and allows cross-workspace resource usage.


Vulnerability Details

Validation Layer

File:

vendor/mettle/sendportal-core/src/Http/Requests/CampaignStoreRequest.php

Vulnerable code:

'email_service_id' => [
    'required',
    'integer',
    'exists:sendportal_email_services,id',
],

The validation rule checks only that the supplied ID exists anywhere in the database. No workspace scoping is applied.

Dispatch Layer

File:

vendor/mettle/sendportal-core/src/Services/Messages/ResolveEmailService.php

Relevant code:

$campaign = $this->campaignTenantRepository->find(
    $message->workspace_id,
    $message->source_id,
    ['email_service']
);

$emailService = $campaign->email_service;

The dispatch workflow resolves the referenced EmailService through the stored foreign key without validating that:

$emailService->workspace_id === $message->workspace_id

As a result, dispatch proceeds using a resource owned by another tenant.


Steps to Reproduce

Setup

Workspace A

  • Email Service:

    • id = 1
    • workspace_id = 1

Workspace B

  • No email services configured

Step 1

Using a Workspace B API token, create a campaign referencing Workspace A's email service:

POST /api/v1/campaigns
Authorization: Bearer <workspace-b-token>
Content-Type: application/json

{
  "name": "Bob Campaign",
  "subject": "Hello",
  "from_name": "Bob",
  "from_email": "bob@example.com",
  "email_service_id": 1,
  "send_to_all": 1,
  "content": "test"
}

Step 2

The request succeeds and the campaign is created:

{
  "id": 2,
  "workspace_id": 2,
  "email_service_id": 1
}

Step 3

During dispatch, the application resolves the foreign tenant resource:

campaign_workspace_id      = 2
email_service_id           = 1
email_service_workspace_id = 1

The campaign belongs to Workspace B while the resolved EmailService belongs to Workspace A.


Impact

  • Cross-tenant resource access
  • Broken tenant isolation
  • Authorization bypass through user-controlled identifier
  • Campaign dispatch workflow resolves and operates on resources belonging to another workspace

Recommended Fix

Scope validation to the current workspace:

Rule::exists('sendportal_email_services', 'id')
    ->where('workspace_id', Sendportal::currentWorkspaceId())

Additionally, enforce ownership validation during dispatch:

if ((int) $emailService->workspace_id !== (int) $message->workspace_id) {
    throw new Exception(
        'Email service does not belong to campaign workspace.'
    );
}

Disclosure

Found during security research. Happy to provide additional information, proof-of-concept material, or assist with validation if required.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions