From dfe741698df527e5642bdf12ea2d6cac734027f7 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 26 Sep 2025 15:09:43 +0200 Subject: [PATCH] Added way to allow/block alias target at creation/modification. see #2013 --- .../src/components/tools/ParametersForm.vue | 2 +- modoboa/admin/api/v2/serializers.py | 9 +++ modoboa/admin/app_settings.py | 39 +++++++++++ modoboa/admin/models/alias.py | 17 +++++ modoboa/admin/tests/test_alias_targets.py | 68 +++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 modoboa/admin/tests/test_alias_targets.py diff --git a/frontend/src/components/tools/ParametersForm.vue b/frontend/src/components/tools/ParametersForm.vue index 710bbb36f..1b0576402 100644 --- a/frontend/src/components/tools/ParametersForm.vue +++ b/frontend/src/components/tools/ParametersForm.vue @@ -169,7 +169,7 @@ function display(element) { element.display.split('&').forEach((rule) => { let [field, value] = rule.split('=') if (value === 'true' || value === 'false') { - value = Boolean(value) + value = value === 'true' } result &= parameters.value[field] === value }) diff --git a/modoboa/admin/api/v2/serializers.py b/modoboa/admin/api/v2/serializers.py index 5010df71d..ef2a9b1fb 100644 --- a/modoboa/admin/api/v2/serializers.py +++ b/modoboa/admin/api/v2/serializers.py @@ -244,6 +244,15 @@ class AdminGlobalParametersSerializer(serializers.Serializer): auto_create_domain_and_mailbox = serializers.BooleanField(default=True) create_alias_on_mbox_rename = serializers.BooleanField(default=False) + # Aliases settings + alias_can_target_any_domain = serializers.BooleanField(default=True) + alias_target_block_list = serializers.ListField( + child=serializers.CharField(), required=False + ) + alias_target_allow_list = serializers.ListField( + child=serializers.CharField(), required=False + ) + def validate_default_domain_quota(self, value): """Ensure quota is a positive integer.""" if value < 0: diff --git a/modoboa/admin/app_settings.py b/modoboa/admin/app_settings.py index f7ebec3ad..1ddbfad1c 100644 --- a/modoboa/admin/app_settings.py +++ b/modoboa/admin/app_settings.py @@ -239,6 +239,45 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict( ), }, ), + ( + "aliases", + { + "label": gettext_lazy("Aliases"), + "params": collections.OrderedDict( + [ + ( + "alias_can_target_any_domain", + { + "label": gettext_lazy("Alias can target any domain"), + "help_text": gettext_lazy( + "Allow aliases to target any domain, not just the local domains." + ), + }, + ), + ( + "alias_target_block_list", + { + "label": gettext_lazy("Alias target block list"), + "help_text": gettext_lazy( + "A list of domains that aliases cannot target (comma separated)." + ), + "display": "alias_can_target_any_domain=true", + }, + ), + ( + "alias_target_allow_list", + { + "label": gettext_lazy("Alias target allow list"), + "help_text": gettext_lazy( + "A list of domains that aliases can target (comma separated)." + ), + "display": "alias_can_target_any_domain=false", + }, + ), + ] + ), + }, + ), ] ) diff --git a/modoboa/admin/models/alias.py b/modoboa/admin/models/alias.py index c3e1b1e58..1ded3caf3 100644 --- a/modoboa/admin/models/alias.py +++ b/modoboa/admin/models/alias.py @@ -21,6 +21,7 @@ from modoboa.lib.exceptions import ( ModoboaException, AliasExists, ) +from modoboa.parameters import tools as param_tools from .. import signals from .base import AdminObject from .domain import Domain @@ -90,6 +91,20 @@ class AliasManager(models.Manager): ) +def is_alias_target_allowed(domain: str) -> bool: + """Check if alias target is allowed or not.""" + params = dict(param_tools.get_global_parameters(app="admin")) + if params.get("alias_can_target_any_domain"): + blocked_domains = params.get("alias_target_block_list", "").split(",") + if domain in blocked_domains: + return False + else: + allowed_domains = params.get("alias_target_allow_list", "").split(",") + if domain not in allowed_domains: + return False + return True + + class Alias(AdminObject): """Mailbox alias.""" @@ -182,6 +197,8 @@ class Alias(AdminObject): if domname is None: raise BadRequest("{} {}".format(_("Invalid address"), address)) domain = Domain.objects.filter(name=domname).first() + if not domain and not is_alias_target_allowed(domname): + raise BadRequest(f"{_('Target domain not allowed')}: {domname}") kwargs = {"address": address, "alias": self} if (domain is not None) and ( any( diff --git a/modoboa/admin/tests/test_alias_targets.py b/modoboa/admin/tests/test_alias_targets.py new file mode 100644 index 000000000..bce94aaf3 --- /dev/null +++ b/modoboa/admin/tests/test_alias_targets.py @@ -0,0 +1,68 @@ +"""Tests for alias target allow/block settings.""" + +from django.urls import reverse + +from modoboa.admin import factories, models +from modoboa.lib.tests import ModoAPITestCase + + +class AliasTargetSettingsTestCase(ModoAPITestCase): + + @classmethod + def setUpTestData(cls): # NOQA:N802 + super().setUpTestData() + factories.populate_database() + + def test_block_external_domain_when_any_allowed_with_block_list(self): + # alias_can_target_any_domain = True and block external domain + self.set_global_parameter("alias_can_target_any_domain", True, app="admin") + self.set_global_parameter("alias_target_block_list", "bad.com", app="admin") + + values = { + "address": "list@test.com", + "recipients": ["user@bad.com"], + "enabled": True, + } + resp = self.client.post(reverse("v2:alias-list"), values, format="json") + # Creation should fail + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.json()["error"], "Target domain not allowed: bad.com") + + def test_allow_only_list_when_any_disallowed(self): + # alias_can_target_any_domain = False and allow list contains domain + self.set_global_parameter("alias_can_target_any_domain", False, app="admin") + self.set_global_parameter("alias_target_allow_list", "ok.com", app="admin") + + # Check if local domain is still allowed + values = { + "address": "list2@test.com", + "recipients": ["user@test.com"], + "enabled": True, + } + resp = self.client.post(reverse("v2:alias-list"), values, format="json") + self.assertEqual(resp.status_code, 201) + + values = { + "address": "list3@test.com", + "recipients": ["user@ok.com"], + "enabled": True, + } + resp = self.client.post(reverse("v2:alias-list"), values, format="json") + self.assertEqual(resp.status_code, 201) + alias = models.Alias.objects.get(address="list3@test.com", internal=False) + # Allowed external domain should remain external + self.assertTrue( + alias.aliasrecipient_set.filter( + address="user@ok.com", r_mailbox__isnull=True, r_alias__isnull=True + ).exists() + ) + + # Now try a domain not in allow list, should stay external as well + values = { + "address": "list4@test.com", + "recipients": ["user@bad.com"], + "enabled": True, + } + resp = self.client.post(reverse("v2:alias-list"), values, format="json") + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.json()["error"], "Target domain not allowed: bad.com")