Authentication Bypass by Primary Weakness in modoboa/modoboa
Description
Authentication Bypass by Primary Weakness in GitHub repository modoboa/modoboa prior to 2.0.4.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Modoboa versions before 2.0.4 allow authentication bypass via weak password reset tokens, enabling account takeover without prior authentication.
Vulnerability
Overview CVE-2023-0777 is an authentication bypass vulnerability in Modoboa, a mail hosting management platform. The root cause is a weak primary key mechanism in the password reset functionality, which allows an attacker to guess or brute-force reset tokens due to insufficient randomness or lack of rate limiting [1]. This bypasses authentication for any account, including administrators.
Exploitation
An attacker can exploit this vulnerability by initiating a password reset request for a known username and then repeatedly submitting different token values until a valid one is found. The attack requires no authentication and minimal preconditions—only the target's email or username. The original implementation did not throttle these attempts, making brute-force feasible [2].
Impact
Successful exploitation grants the attacker full control over the target account, including the ability to change passwords, access administrative functions, and manage mail domains. This can lead to complete compromise of the mail hosting infrastructure [3].
Mitigation
The issue was addressed in Modoboa version 2.0.4, which introduced throttling for the reset password endpoint (via PasswordResetRequestThrottle) and presumably improved token generation [2]. Users are strongly advised to upgrade to 2.0.4 or later. No workarounds have been provided for earlier versions [4].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
modoboaPyPI | < 2.0.4 | 2.0.4 |
Affected products
2- modoboa/modoboa/modoboav5Range: unspecified
Patches
147d17ac6643fMerge pull request #2767 from modoboa/api-throttling
22 files changed · +228 −25
doc/upgrade.rst+32 −1 modified@@ -123,6 +123,7 @@ The following modifications must be applied to the :file:`settings.py` file: }, ] + * Add the following variable:: .. sourcecode:: python @@ -141,10 +142,40 @@ The following modifications must be applied to the :file:`settings.py` file: }, -You now have the possibility to customize the url of the new-admin +* You now have the possibility to customize the url of the new-admin interface. To do so please head up to :ref:`the custom configuration chapter <customization>` (advanced user). +* Add ``DEFAULT_THROTTLE_RATES`` to ``REST_FRAMEWORK``: + +.. sourcecode:: python + + REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_RATES': { + 'user': '300/minute', + 'ddos': '5/second', + 'ddos_lesser': '200/minute', + 'login': '10/minute', + 'password_recovery_request': '12/hour', + 'password_recovery_totp_check': '25/hour', + 'password_recovery_apply': '25/hour' + }, + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning', + } + +* You can edit the ``DEFAULT_THROTTLE_RATES`` to whatever value suits you. + - `user` is for every endpoint, it is per user or per ip if not logged. + - `ddos` is per api endpoint and per user or per ip if not logged. + - `ddos_lesser` is for per api endpoint and per user or per ip if not logged. This is for api endpoint that are lighter. + - `login` the number of time an ip can attempt to log. The counter will reset on login success. + - `password_` is for the recovery, it is divided per step in the recovery process. + 2.0.3 =====
frontend/src/api/repository.js+7 −0 modified@@ -3,8 +3,11 @@ import Cookies from 'js-cookie' import router from '../router' import store from '../store' +import { translate } from 'vue-gettext' +import { bus } from '@/main' const _axios = axios.create() +const { gettext: $gettext } = translate _axios.interceptors.request.use( function (config) { @@ -31,6 +34,10 @@ _axios.interceptors.response.use( router.push({ name: 'TwoFA' }) return Promise.reject(error) } + if (error.response.status === 429) { + bus.$emit('notification', { msg: $gettext('You are throttled, please try later.'), type: 'error' }) + return Promise.reject(error) + } if (error.response.status !== 401 || router.currentRoute.path === '/login/') { return Promise.reject(error) }
frontend/src/App.vue+5 −3 modified@@ -60,9 +60,11 @@ export default { }), methods: { showNotification (options) { - this.notification = options.msg - this.notificationColor = (options.type) ? options.type : 'success' - this.snackbar = true + if (this.isAuthenticated) { + this.notification = options.msg + this.notificationColor = (options.type) ? options.type : 'success' + this.snackbar = true + } } } }
frontend/src/views/Login.vue+4 −0 modified@@ -94,6 +94,10 @@ export default { this.$refs.observer.setErrors({ password: this.$gettext('Invalid username and/or password') }) + } else if (err.response.status === 429) { + this.$refs.observer.setErrors({ + password: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } }) }
frontend/src/views/user/PasswordRecoveryChangeForm.vue+2 −1 modified@@ -81,7 +81,6 @@ export default { } else { const decodedId = atob(this.$route.params.id) if (!/^\d+$/.test(decodedId)) { - console.error('Received ID is invalid') this.$router.push({ name: 'PasswordRecoveryForm' }) } else { this.id = this.$route.params.id @@ -119,6 +118,8 @@ export default { err.response.data.errors.forEach(element => { message += this.$gettext(element) + '<br>' }) + } else if (err.response.status === 429) { + message = this.$gettext('Too many unsuccessful attempts, please try later.') } this.password_validation_error = message })
frontend/src/views/user/PasswordRecoveryForm.vue+4 −0 modified@@ -110,6 +110,10 @@ export default { } } else if (err.response.status === 503 && err.response.data.type === 'email') { this.showDialog('Error', err.response.data.reason, true) + } else if (err.response.status === 429) { + this.$refs.observer.setErrors({ + email: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } }) }
frontend/src/views/user/PasswordRecoverySmsTotpForm.vue+5 −1 modified@@ -82,13 +82,17 @@ export default { this.loading = false if (resp.status === 200) { this.$refs.observer.setErrors({ - password_confirmed: this.$gettext('TOTP resent.') + sms_totp: this.$gettext('TOTP resent.') }) } }).catch(err => { if (err.response.status === 400) { this.loading = false this.showErrorDialog(this.$t('User seems wrong, return to login or restart reset the process?')) + } else if (err.response.status === 429) { + this.$refs.observer.setErrors({ + sms_totp: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } }) },
modoboa/admin/api/v1/viewsets.py+14 −5 modified@@ -16,6 +16,7 @@ from modoboa.core import sms_backends from modoboa.lib import renderers as lib_renderers from modoboa.lib import viewsets as lib_viewsets +from modoboa.lib.throttle import GetThrottleViewsetMixin, PasswordResetRequestThrottle from ... import lib, models from . import serializers @@ -35,7 +36,7 @@ summary="Create a new domain" ) ) -class DomainViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): +class DomainViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """Domain viewset.""" permission_classes = [IsAuthenticated, DjangoModelPermissions, ] @@ -60,7 +61,7 @@ class Meta: fields = ["domain"] -class DomainAliasViewSet(lib_viewsets.RevisionModelMixin, +class DomainAliasViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """ViewSet for DomainAlias.""" @@ -80,13 +81,21 @@ def get_renderer_context(self): return context -class AccountViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): +class AccountViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """ViewSet for User/Mailbox.""" filter_backends = (filters.SearchFilter, ) permission_classes = [IsAuthenticated, DjangoModelPermissions, ] search_fields = ("^first_name", "^last_name", "^email") + def get_throttles(self): + + throttles = super().get_throttles() + if self.action == "reset_password": + throttles.append(PasswordResetRequestThrottle()) + + return throttles + def get_serializer_class(self): """Return a serializer.""" action_dict = { @@ -175,7 +184,7 @@ def reset_password(self, request): return Response(body) -class AliasViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): +class AliasViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """ create: Create a new alias instance. @@ -207,7 +216,7 @@ class Meta: fields = ["mailbox"] -class SenderAddressViewSet(lib_viewsets.RevisionModelMixin, +class SenderAddressViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """View set for SenderAddress model."""
modoboa/admin/api/v2/tests.py+1 −0 modified@@ -224,6 +224,7 @@ def test_create_with_bad_password(self): self.assertIn("password", resp.json()) def test_validate(self): + """Test validate and throttling.""" data = {"username": "toto@test.com"} url = reverse("v2:account-validate") resp = self.client.post(url, data, format="json")
modoboa/admin/api/v2/viewsets.py+6 −4 modified@@ -16,6 +16,7 @@ from modoboa.core import models as core_models from modoboa.lib import renderers as lib_renderers from modoboa.lib import viewsets as lib_viewsets +from modoboa.lib.throttle import GetThrottleViewsetMixin from ... import lib from ... import models @@ -40,7 +41,7 @@ summary="Delete a particular domain" ), ) -class DomainViewSet(lib_viewsets.RevisionModelMixin, +class DomainViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, @@ -160,6 +161,7 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend) filterset_class = AccountFilterSet + def get_serializer_class(self): if self.action in ["create", "validate", "update", "partial_update"]: return serializers.WritableAccountSerializer @@ -208,7 +210,7 @@ def delete(self, request, **kwargs): return response.Response(status=status.HTTP_204_NO_CONTENT) -class IdentityViewSet(viewsets.ViewSet): +class IdentityViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Viewset for identities.""" permission_classes = (permissions.IsAuthenticated, ) @@ -270,7 +272,7 @@ def random_address(self, request, **kwargs): }) -class UserAccountViewSet(viewsets.ViewSet): +class UserAccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Viewset for current user operations.""" @action(methods=["get", "post"], detail=False) @@ -323,7 +325,7 @@ def forward(self, request, **kwargs): return response.Response(serializer.validated_data) -class AlarmViewSet(viewsets.ReadOnlyModelViewSet): +class AlarmViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet): """Viewset for Alarm.""" filter_backends = (filters.OrderingFilter, filters.SearchFilter, )
modoboa/core/api/v1/viewsets.py+5 −1 modified@@ -5,14 +5,18 @@ import django_otp from django_otp.plugins.otp_static.models import StaticDevice, StaticToken from django_otp.plugins.otp_totp.models import TOTPDevice + from rest_framework import permissions, response, viewsets from rest_framework.decorators import action + from drf_spectacular.utils import extend_schema +from modoboa.lib.throttle import GetThrottleViewsetMixin + from . import serializers -class AccountViewSet(viewsets.ViewSet): +class AccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Account viewset. Contains endpoints used to manipulate current user's account.
modoboa/core/api/v2/viewsets.py+3 −2 modified@@ -17,6 +17,7 @@ from modoboa.core.api.v1 import serializers as core_v1_serializers from modoboa.core.api.v1 import viewsets as core_v1_viewsets from modoboa.lib import pagination +from modoboa.lib.throttle import GetThrottleViewsetMixin from ... import constants from ... import models @@ -148,7 +149,7 @@ def tfa_setup_check(self, request): }) -class LogViewSet(viewsets.ReadOnlyModelViewSet): +class LogViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet): """Log viewset.""" filter_backends = [filters.OrderingFilter, filters.SearchFilter] @@ -164,7 +165,7 @@ class LogViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.LogSerializer -class LanguageViewSet(viewsets.ViewSet): +class LanguageViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Language viewset.""" permission_classes = (
modoboa/core/api/v2/views.py+28 −0 modified@@ -16,6 +16,7 @@ from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates +from modoboa.lib.throttle import UserLesserDdosUser, LoginThrottle, PasswordResetApplyThrottle, PasswordResetRequestThrottle, PasswordResetTotpThrottle from modoboa.parameters import tools as param_tools from smtplib import SMTPException @@ -25,9 +26,20 @@ logger = logging.getLogger("modoboa.auth") +def delete_cache_key(class_target, throttles, request): + """Attempt to delete cache key from throttling on login/password reset success.""" + + for throttle in throttles: + if type(throttle) == class_target: + throttle.reset_cache(request) + return + + class TokenObtainPairView(jwt_views.TokenObtainPairView): """We overwrite this view to deal with password scheme update.""" + throttle_classes = [LoginThrottle] + def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) try: @@ -42,6 +54,10 @@ def post(self, request, *args, **kwargs): user = serializer.user login(request, user) + + # Reset login throttle + delete_cache_key(LoginThrottle, self.get_throttles(), request) + logger.info( _("User '%s' successfully logged in"), user.username ) @@ -85,6 +101,8 @@ class EmailPasswordResetView(APIView): An Api View which provides a method to request a password reset token based on an e-mail address. """ + throttle_classes = [PasswordResetRequestThrottle] + def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryEmailSerializer( data=request.data, context={'request': request}) @@ -96,6 +114,7 @@ def post(self, request, *args, **kwargs): "type": "email", "reason": "Error while sending the email. Please contact an administrator." }, 503) + # Email response return response.Response({"type": "email"}, 200) @@ -114,13 +133,16 @@ def post(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) except serializers.NoSMSAvailable: return super().post(request, *args, **kwargs) + # SMS response return response.Response({"type": "sms"}, 200) class PasswordResetSmsTOTP(APIView): """ Check SMS Totp code. """ + throttle_classes = [PasswordResetTotpThrottle] + def post(self, request, *args, **kwargs): try: if request.data["type"] == "confirm": @@ -140,12 +162,15 @@ def post(self, request, *args, **kwargs): "id": serializer_response[1], "type": "confirm" }) + delete_cache_key(PasswordResetTotpThrottle, self.get_throttles(), request) return response.Response(payload, 200) class PasswordResetConfirmView(APIView): """ Get and set new user password. """ + throttle_classes = [PasswordResetApplyThrottle] + def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryConfirmSerializer( data=request.data) @@ -159,12 +184,15 @@ def post(self, request, *args, **kwargs): data.update({"errors": errors}) return response.Response(data, 400) serializer.save() + delete_cache_key(PasswordResetApplyThrottle, self.get_throttles(), request) return response.Response(status=200) class ComponentsInformationAPIView(APIView): """Retrieve information about installed components.""" + throttle_classes = [UserLesserDdosUser] + @extend_schema(responses=serializers.ModoboaComponentSerializer(many=True)) def get(self, request, *args, **kwargs): status, extensions = check_for_updates()
modoboa/core/commands/templates/settings.py.tpl+9 −0 modified@@ -193,6 +193,15 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Rest framework settings REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_RATES': { + 'user': '300/minute', + 'ddos': '5/second', + 'ddos_lesser': '200/minute', + 'login': '10/minute', + 'password_recovery_request': '12/hour', + 'password_recovery_totp_check': '25/hour', + 'password_recovery_apply': '25/hour' + }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', 'rest_framework.authentication.TokenAuthentication',
modoboa/dnstools/api/v2/viewsets.py+2 −1 modified@@ -4,11 +4,12 @@ from rest_framework.decorators import action from modoboa.admin import models as admin_models +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers -class DNSViewSet(viewsets.GenericViewSet): +class DNSViewSet(GetThrottleViewsetMixin, viewsets.GenericViewSet): """A viewset to provide extra routes related to DNS information.""" permission_classes = (permissions.IsAuthenticated, )
modoboa/lib/throttle.py+79 −0 added@@ -0,0 +1,79 @@ +from rest_framework.throttling import SimpleRateThrottle, UserRateThrottle +from django.urls import resolve + +class UserDdosPerView(SimpleRateThrottle): + """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" + + scope = 'ddos' + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + return self.cache_format % { + 'scope': hash(resolve(request.path).url_name), + 'ident': ident + } + + +class UserLesserDdosUser(SimpleRateThrottle): + """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" + + scope = 'ddos_lesser' + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + return self.cache_format % { + 'scope': hash(resolve(request.path).url_name), + 'ident': ident + } + + +class LoginThrottle(SimpleRateThrottle): + """ Custom throttle to reset the cache counter on success. """ + + scope = 'login' + + def get_cache_key(self, request, view): + return self.cache_format % { + 'scope': self.scope, + 'ident': self.get_ident(request) + } + + def reset_cache(self, request): + self.key = self.get_cache_key(request, None) + self.cache.delete(self.key) + + +class PasswordResetRequestThrottle(LoginThrottle): + + scope = 'password_recovery_request' + + +class PasswordResetTotpThrottle(LoginThrottle): + + scope = 'password_recovery_totp_check' + + +class PasswordResetApplyThrottle(LoginThrottle): + + scope = 'password_recovery_apply' + + +class GetThrottleViewsetMixin(): + """Override default get_throttle behaviour to assign throttle classes to different actions.""" + + def get_throttles(self): + """Give lesser_ddos to GET type actions and ddos to others.""" + + throttles = [UserRateThrottle()] + + if self.action in ["list", "retrieve", "validate", "dns_detail", "me", "dns_detail", "applications", "structure"]: + throttles.append(UserLesserDdosUser()) + else: + throttles.append(UserDdosPerView()) + return throttles \ No newline at end of file
modoboa/limits/api/v1/viewsets.py+2 −1 modified@@ -6,10 +6,11 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from modoboa.core import models as core_models +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers -class ResourcesViewSet( +class ResourcesViewSet(GetThrottleViewsetMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
modoboa/maillog/api/v2/viewsets.py+3 −2 modified@@ -9,13 +9,14 @@ from modoboa.admin import models as admin_models from modoboa.lib import pagination +from modoboa.lib.throttle import GetThrottleViewsetMixin from ... import models from ... import signals from . import serializers -class StatisticsViewSet(viewsets.ViewSet): +class StatisticsViewSet(GetThrottleViewsetMixin,viewsets.ViewSet): """A viewset to provide extra route related to mail statistics.""" permission_classes = (permissions.IsAuthenticated, ) @@ -51,7 +52,7 @@ def list(self, request, **kwargs): return response.Response({"graphs": graphs}) -class MaillogViewSet(viewsets.ReadOnlyModelViewSet): +class MaillogViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet): """Simple viewset to access message log.""" filter_backends = [filters.OrderingFilter, filters.SearchFilter]
modoboa/parameters/api/v2/viewsets.py+3 −1 modified@@ -4,11 +4,13 @@ from rest_framework import response, viewsets from rest_framework.decorators import action +from modoboa.lib.throttle import GetThrottleViewsetMixin + from . import serializers from ... import tools -class ParametersViewSet(viewsets.ViewSet): +class ParametersViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Parameter viewset.""" lookup_value_regex = r"\w+"
modoboa/relaydomains/api/v1/viewsets.py+2 −1 modified@@ -4,11 +4,12 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from modoboa.admin import models as admin_models +from modoboa.lib.throttle import GetThrottleViewsetMixin from modoboa.lib.viewsets import RevisionModelMixin from . import serializers -class RelayDomainViewSet(RevisionModelMixin, viewsets.ModelViewSet): +class RelayDomainViewSet(GetThrottleViewsetMixin, RevisionModelMixin, viewsets.ModelViewSet): """RelayDomain viewset.""" permission_classes = [IsAuthenticated, DjangoModelPermissions, ]
modoboa/transport/api/v2/viewsets.py+3 −1 modified@@ -3,11 +3,13 @@ from drf_spectacular.utils import extend_schema from rest_framework import permissions, response, viewsets +from modoboa.lib.throttle import GetThrottleViewsetMixin + from . import serializers from ... import backends -class TransportViewSet(viewsets.ViewSet): +class TransportViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Viewset for Transport.""" permissions = (permissions.IsAuthenticated, )
test_project/test_project/settings.py+9 −0 modified@@ -186,6 +186,15 @@ # Rest framework settings REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_RATES': { + 'user': '200/minute', + 'ddos': '10/second', + 'ddos_lesser': '200/minute', + 'login': '10/minute', + 'password_recovery_request': '11/hour', + 'password_recovery_totp_check': '25/hour', + 'password_recovery_apply': '25/hour' + }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', 'rest_framework.authentication.TokenAuthentication',
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-rfhw-fm4m-52j6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-0777ghsaADVISORY
- packetstormsecurity.com/files/171744/modoboa-2.0.4-Admin-Takeover.htmlghsaWEB
- github.com/modoboa/modoboa/commit/47d17ac6643f870719691073956a26e4be0a4806ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/modoboa/PYSEC-2023-32.yamlghsaWEB
- huntr.dev/bounties/a17e7a9f-0fee-4130-a522-5a0466fc17c7ghsaWEB
News mentions
0No linked articles in our index yet.