mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-08 10:13:37 +00:00
[Web][Postfix][Dovecot] Mail policies: compare addresses like Postfix, close bypasses via alias forms, temporary aliases, Sieve and cross-domain groups
This commit is contained in:
22 files changed
+614
-141
No files matched your search
@@ -46,9 +46,9 @@ class PolicyEngineTest extends TestCase
|
||||
}
|
||||
|
||||
/** [allowed, side] of a check */
|
||||
private function check(string $sender, string $recipient, string $login = '', int $port = 587): array
|
||||
private function check(string $sender, string $recipient, string $login = '', int $port = 587, string $helo = ''): array
|
||||
{
|
||||
$decision = $this->engine->check($sender, $recipient, $login, $port);
|
||||
$decision = $this->engine->check($sender, $recipient, $login, $port, $helo);
|
||||
return [$decision->allowed, $decision->side];
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class PolicyEngineTest extends TestCase
|
||||
|
||||
public function testInternalFlagOfAliasesAndGroups(): void
|
||||
{
|
||||
$this->lookup->internalAliases['team@firma.de'] = 'firma.de';
|
||||
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team@firma.de', '', 25));
|
||||
// the old rspamd rule let forged senders of the domain pass
|
||||
@@ -174,15 +174,123 @@ class PolicyEngineTest extends TestCase
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(12, ['sendMode' => 'internal'])];
|
||||
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(13, ['receiveMode' => 'internal'])];
|
||||
$this->lookup->internalAliases['team@firma.de'] = 'firma.de';
|
||||
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
|
||||
// a Sieve redirect of azubi to an external address follows its send rules
|
||||
$this->assertSame([false, 'send'], $this->check('azubi+x@firma.de', 'x@extern.example', '', 588));
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', '', 588));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', '', 588));
|
||||
// notifications of mailcow (e.g. quota warnings) are no mailbox
|
||||
$this->assertSame([true, ''], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588));
|
||||
// other senders are checked like mail from outside
|
||||
$this->assertSame([false, 'receive'], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588));
|
||||
// the quota warning copies of Dovecot carry the address of the mailbox over its quota
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'x@extern.example', '', 588, 'quotanotification'));
|
||||
// on other ports the sender address is no login
|
||||
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.de', '', 25));
|
||||
}
|
||||
|
||||
public function testDovecotMailWithoutMailboxSender(): void
|
||||
{
|
||||
// bounces redirected with the null sender, notifications of other senders: local recipients only while
|
||||
// a policy restricts sending, as the sending mailbox is unknown
|
||||
$this->assertSame([true, ''], $this->check('', 'x@extern.example', '', 588));
|
||||
$this->lookup->sendRules = true;
|
||||
$this->assertSame([false, 'send'], $this->check('', 'x@extern.example', '', 588));
|
||||
$this->assertSame([false, 'send'], $this->check('irgendwer@nirgends.example', 'x@extern.example', '', 588));
|
||||
$this->assertSame([true, ''], $this->check('', 'kollege@firma.com', '', 588));
|
||||
// local recipients: receive rules as for mail from outside
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(14, ['sendMode' => 'internal', 'receiveMode' => 'internal'])];
|
||||
$this->assertSame([false, 'receive'], $this->check('', 'azubi@firma.de', '', 588));
|
||||
// SOGo reminders log in with the Dovecot master user
|
||||
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'x@extern.example', 'abc@mailcow.local', 588));
|
||||
$this->assertSame([false, 'send'], $this->check('', 'x@extern.example', 'abc@mailcow.local', 588));
|
||||
}
|
||||
|
||||
public function testRecipientsAreComparedAsPostfixDeliversThem(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(15, ['receiveMode' => 'internal'])];
|
||||
|
||||
// trailing dot and quotes are dropped by Postfix
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'Azubi@Firma.de.', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', '"azubi+a b"@firma.com', '', 25));
|
||||
// routes through another address are refused, Postfix would deliver them to the routed address
|
||||
foreach (['azubi@firma.de@localhost', 'azubi%firma.de@localhost', 'firma.de!azubi@localhost.localdomain', 'x@extern.example@localhost',
|
||||
'azubi%firma.de@[192.0.2.1]', 'abuse'] as $recipient) {
|
||||
$this->assertSame([false, 'address'], $this->check('x@extern.example', $recipient, '', 25), $recipient);
|
||||
}
|
||||
// in other domains %, ! and @ in the local part are ordinary characters
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'a!b@extern.example', 'kollege@firma.de'));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'sales%x@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'postmaster', '', 25));
|
||||
$this->assertSame('550 5.7.1 This form of the recipient address is not allowed by the mail policy',
|
||||
$this->engine->check('azubi@firma.de', 'x%extern.example@localhost', 'azubi@firma.de', 587)->action());
|
||||
}
|
||||
|
||||
public function testSendersAreComparedAsPostfixResolvesThem(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(16, ['sendBlock' => ['konkurrenz.example']])];
|
||||
$this->lookup->mailboxPolicies['info@firma.de'] = [self::policy(17, ['sendMode' => 'internal'])];
|
||||
|
||||
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@konkurrenz.example.', 'kollege@firma.de'));
|
||||
// sending as info@ with a trailing dot keeps the policy of info@
|
||||
$this->assertSame([false, 'send'], $this->check('Info@firma.de.', 'x@extern.example', 'chef@firma.de'));
|
||||
}
|
||||
|
||||
public function testTemporaryAliasesFollowTheirOwner(): void
|
||||
{
|
||||
$this->lookup->temporaryAliases['einmal.xyz@firma.de'] = 'azubi@firma.de';
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(18, ['receiveMode' => 'internal'])];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'einmal.xyz@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'einmal.xyz@firma.de', 'kollege@firma.de'));
|
||||
}
|
||||
|
||||
public function testInternalFlagFollowsThePostfixLookup(): void
|
||||
{
|
||||
// an alias of its own in an alias domain; senders of the target domain are internal
|
||||
$this->lookup->aliases['team2@firma.com'] = ['domain' => 'firma.com', 'internal' => true];
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team2@firma.com', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team2@firma.com', 'kollege@firma.de'));
|
||||
// the full address first, then without +tag
|
||||
$this->lookup->aliases['team+ext@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => false];
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team+ext@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'team+other@firma.de', '', 25));
|
||||
// an internal catch-all alias covers the whole domain, also through its alias domain
|
||||
$this->lookup->internalCatchAlls = ['tochter.de'];
|
||||
$this->lookup->aliasDomains['tochter.com'] = 'tochter.de';
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'andere@tochter.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'unbekannt@tochter.com', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('andere@tochter.de', 'unbekannt@tochter.de', 'andere@tochter.de'));
|
||||
}
|
||||
|
||||
public function testAddressesAreComparedInTheirStoredSpelling(): void
|
||||
{
|
||||
// MySQL ignores accents: Postfix delivers chéf@ to chef@ and töchter.de to tochter.de
|
||||
$this->lookup->spellings = ['chéf@firma.de' => 'chef@firma.de', 'töchter.de' => 'tochter.de', 'köllege@firma.de' => 'kollege@firma.de'];
|
||||
$this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(20, ['sendBlock' => ['chef@firma.de', 'tochter.de']])];
|
||||
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(21, ['receiveBlock' => ['kollege@firma.de']])];
|
||||
|
||||
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'chéf@firma.de', 'kollege@firma.de'));
|
||||
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@töchter.de', 'kollege@firma.de'));
|
||||
$this->assertSame([false, 'receive'], $this->check('köllege@firma.de', 'chef@firma.de', '', 25));
|
||||
}
|
||||
|
||||
public function testTaggedAliasThroughAnAliasDomain(): void
|
||||
{
|
||||
// Postfix maps chef@firma.com to chef@firma.de and looks up chef+intern@firma.de again
|
||||
$this->lookup->aliases['chef+intern@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'chef+intern@firma.com', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'chef+other@firma.com', '', 25));
|
||||
}
|
||||
|
||||
public function testBlockEntriesAlsoMatchTheEnvelopeSender(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(19, ['receiveBlock' => ['kollege@firma.de', 'group:7', 'tochter.de']])];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('info@firma.de', 'chef@firma.de', '', 25));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class PolicyServiceTest extends TestCase
|
||||
private FakeMailPolicyLookup $lookup;
|
||||
/** @var Policy[] saved policies */
|
||||
private array $saved = [];
|
||||
private int $groupLookups = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -30,7 +31,11 @@ class PolicyServiceTest extends TestCase
|
||||
$this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true));
|
||||
$this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string =>
|
||||
['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null);
|
||||
$this->repo->method('groupDomain')->willReturnCallback(fn(int $id): ?string => [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null);
|
||||
$this->repo->method('groupDomain')->willReturnCallback(function (int $id): ?string {
|
||||
$this->groupLookups++;
|
||||
return [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null;
|
||||
});
|
||||
$this->repo->method('groupsIn')->willReturn([['id' => 7, 'name' => 'Azubis', 'domain' => 'firma.de', 'address' => null]]);
|
||||
$this->repo->method('save')->willReturnCallback(function (Policy $policy): int {
|
||||
$policy->id ??= 1;
|
||||
$this->saved[] = clone $policy;
|
||||
@@ -133,7 +138,7 @@ class PolicyServiceTest extends TestCase
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']);
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']);
|
||||
|
||||
$this->assertSame('Test', $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test'])->name);
|
||||
$this->assertNotNull($this->service($da)->get(3));
|
||||
@@ -149,6 +154,58 @@ class PolicyServiceTest extends TestCase
|
||||
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3));
|
||||
}
|
||||
|
||||
public function testGroupsNeedTheGroupAcl(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored(['groups' => [7]]));
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']);
|
||||
|
||||
$this->assertSame([], $this->service($da)->groupChoices(['firma.de']));
|
||||
// the form sends empty group fields: the group entries and assignments stay
|
||||
$policy = $this->service($da)->update(3, ['send_allow' => 'kunde.example', 'send_allow_groups' => '', 'groups' => '']);
|
||||
$this->assertSame([['kunde.example', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]);
|
||||
// the stored values sent back unchanged, as returned by the API
|
||||
$policy = $this->service($da)->update(3, ['send_allow' => ['partner.de', 'group:7'], 'groups' => ['7']]);
|
||||
$this->assertSame([['partner.de', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]);
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->update(3, ['groups' => ['7', '8']]));
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'groups' => ['7']]));
|
||||
$this->expectUserError(['mail_policy_entry_invalid', 'group:7'],
|
||||
fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => 'group:7']));
|
||||
$this->assertCount(1, $this->service(FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']))->groupChoices(['firma.de']));
|
||||
}
|
||||
|
||||
public function testGroupEntriesBelongToThePolicyDomain(): void
|
||||
{
|
||||
// also for admins: the policy of firma.de cannot name a group of tochter.de
|
||||
$this->expectUserError(['mail_policy_entry_invalid', 'group:9'],
|
||||
fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'receive_block' => 'group:9']));
|
||||
}
|
||||
|
||||
public function testListsAreLimitedBeforeAnythingIsLookedUp(): void
|
||||
{
|
||||
$groups = array_map(fn(int $i): string => 'group:' . $i, range(1, 501));
|
||||
$this->expectUserError(['mail_policy_entries_exceeded', 500], fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => $groups]));
|
||||
$this->assertSame(0, $this->groupLookups);
|
||||
// repeats count once
|
||||
$this->assertSame(['group:7'], $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => array_fill(0, 600, 'group:7')])->sendBlock);
|
||||
$this->expectUserError(['mail_policy_entries_exceeded', 500],
|
||||
fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'mailboxes' => array_map(fn(int $i): string => "m$i@firma.de", range(1, 501))]));
|
||||
}
|
||||
|
||||
public function testRuleTestShowsNothingOfOtherDomains(): void
|
||||
{
|
||||
$this->lookup->mailboxes['andere@tochter.de'] = 'tochter.de';
|
||||
$this->lookup->mailboxPolicies['andere@tochter.de'] = [$this->stored(['id' => 5, 'domain' => 'tochter.de', 'name' => 'Geheim',
|
||||
'sendAllow' => [], 'receiveMode' => 'internal', 'rejectText' => 'Interner Text'])];
|
||||
$this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => $this->lookup->mailboxes[$username] ?? null);
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']);
|
||||
|
||||
// a policy of another domain: neither name nor text
|
||||
$this->assertSame(['allowed' => 0, 'side' => 'receive', 'policy' => null, 'internal_alias' => 0, 'logged_in' => 1,
|
||||
'text' => 'This recipient does not accept mail from this sender'], $this->service($da)->test('azubi@firma.de', 'andere@tochter.de', true));
|
||||
// a sender of another domain is not tested as logged in, so its mailbox and its rules stay hidden
|
||||
$this->assertSame(0, $this->service($da)->test('andere@tochter.de', 'chef@firma.de', true)['logged_in']);
|
||||
}
|
||||
|
||||
public function testRuleTest(): void
|
||||
{
|
||||
$policy = $this->stored(['sendMode' => 'internal', 'sendAllow' => []]);
|
||||
|
||||
@@ -21,8 +21,16 @@ class FakeMailPolicyLookup extends LookupRepository
|
||||
public array $domains = [];
|
||||
/** id => ['address' => ?string, 'members' => string[]] */
|
||||
public array $groups = [];
|
||||
/** alias address => domain of aliases with the internal flag */
|
||||
public array $internalAliases = [];
|
||||
/** alias address => ['domain' => string, 'internal' => bool] (active aliases, also own aliases of mailboxes) */
|
||||
public array $aliases = [];
|
||||
/** temporary alias => owner mailbox */
|
||||
public array $temporaryAliases = [];
|
||||
/** @var string[] domains with an internal catch-all alias */
|
||||
public array $internalCatchAlls = [];
|
||||
/** an active policy restricts sending */
|
||||
public bool $sendRules = false;
|
||||
/** other spelling (MySQL ignores accents) => stored address or domain */
|
||||
public array $spellings = [];
|
||||
/** username => Policy[] assigned directly */
|
||||
public array $mailboxPolicies = [];
|
||||
/** group id => Policy[] */
|
||||
@@ -67,9 +75,34 @@ class FakeMailPolicyLookup extends LookupRepository
|
||||
return in_array($username, $this->groups[$groupId]['members'] ?? [], true);
|
||||
}
|
||||
|
||||
public function internalAlias(string $address): ?string
|
||||
public function aliasTarget(string $address): ?array
|
||||
{
|
||||
return $this->internalAliases[$address] ?? null;
|
||||
if (isset($this->aliases[$address])) {
|
||||
return ['kind' => 'alias', 'domain' => $this->aliases[$address]['domain'], 'internal' => $this->aliases[$address]['internal']];
|
||||
}
|
||||
return isset($this->temporaryAliases[$address]) ? ['kind' => 'temporary', 'owner' => $this->temporaryAliases[$address]] : null;
|
||||
}
|
||||
|
||||
public function internalCatchAll(string $domain): bool
|
||||
{
|
||||
return in_array($domain, $this->internalCatchAlls, true);
|
||||
}
|
||||
|
||||
public function hasSendRules(): bool
|
||||
{
|
||||
return $this->sendRules;
|
||||
}
|
||||
|
||||
public function storedAddress(string $address): ?string
|
||||
{
|
||||
$address = $this->spellings[$address] ?? $address;
|
||||
return (isset($this->mailboxes[$address]) || isset($this->aliases[$address]) || isset($this->temporaryAliases[$address])) ? $address : null;
|
||||
}
|
||||
|
||||
public function storedDomain(string $domain): ?string
|
||||
{
|
||||
$domain = $this->spellings[$domain] ?? $domain;
|
||||
return (in_array($domain, $this->domains, true) || isset($this->aliasDomains[$domain])) ? $domain : null;
|
||||
}
|
||||
|
||||
public function policiesForMailbox(string $username): array
|
||||
|
||||
Reference in new issue
Block a user