GitHub Security Advisory: GHSA-vmjj-qr7v-pxm6
Package: froxlor/froxlor (COMPOSER)
Severity: MODERATE (CVSS 5.0)
CWE: CWE-863: Incorrect Authorization
Vulnerable versions: < 2.3.6
Patched in: 2.3.6

Summary: Froxlor has an Email Sender Alias Domain Ownership Bypass via Wrong Array Index Allows Cross-Customer Email Spoofing

Details:
## Summary

In `EmailSender::add()`, the domain ownership validation for full email sender aliases uses the wrong array index when splitting the email address, passing the local part instead of the domain to `validateLocalDomainOwnership()`. This causes the ownership check to always pass for non-existent "domains," allowing any authenticated customer to add sender aliases for email addresses on domains belonging to other customers. Postfix's `sender_login_maps` then authorizes the attacker to send emails as those addresses.

## Details

In `lib/Froxlor/Api/Commands/EmailSender.php` at line 100, when a customer adds a full email address (not a `@domain` wildcard) as an allowed sender, the code splits on `@` and takes index `[0]`:

```php
// Line 96-106
if (substr($allowed_sender, 0, 1) != '@') {
    if (!Validate::validateEmail($idna_convert->encode($allowed_sender))) {
        Response::standardError('emailiswrong', $allowed_sender, true);
    }
    self::validateLocalDomainOwnership(explode("@", $allowed_sender)[0] ?? "");  // BUG: [0] is the local part
} else {
    if (!Validate::validateDomain($idna_convert->encode(substr($allowed_sender, 1)))) {
        Response::standardError('wildcardemailiswrong', substr($allowed_sender, 1), true);
    }
    self::validateLocalDomainOwnership(substr($allowed_sender, 1));  // CORRECT: passes domain
}
```

For input `admin@domain-b.com`, `explode("@", "admin@domain-b.com")` returns `["admin", "domain-b.com"]`. Index `[0]` is `"admin"` — the local part, not the domain.

The `validateLocalDomainOwnership()` function (lines 346-355) then queries `panel_domains` for a domain matching `"admin"`:

```php
private static function validateLocalDomainOwnership(string $domain): void
{
    $sel_stmt = Database::prepare("SELECT customerid FROM `" . TABLE_PANEL_DOMAINS . "` WHERE `domain` = :domain");
    $domain_result = Database::pexecute_first($sel_stmt, ['domain' => $domain]);
    if ($domain_result && $domain_result['customerid'] != CurrentUser::getField('customerid')) {
        Response::standardError('senderdomainnotowned', $domain, true);
    }
}
```

Since no domain named `"admin"` exists in `panel_domains`, `$domain_result` is false, and the function returns without error — the ownership check silently passes.

The inserted `mail_sender_aliases` row is then picked up by Postfix's `sender_login_maps` query (configured in `mysql-virtual_sender_permissions.cf`):

```sql
... UNION (SELECT mail_sender_aliases.email FROM mail_sender_aliases
WHERE mail_sender_aliases.allowed_sender = '%s') ...
```

This query maps the `allowed_sender` back to the mail user, authorizing them to send as that address via SMTP.

## PoC

```bash
# Prerequisites: Froxlor instance with mail.enable_allow_sender enabled,
# two customers: Customer A (owns domain-a.com) and Customer B (owns domain-b.com)

# Step 1: As Customer A, add a sender alias claiming Customer B's domain
# Via API:
curl -X POST 'https://froxlor-host/api/v1/' \
  -H 'Authori