Open Closed

Security Finding in ABP v9.2.4: Unauthenticated Email Enumeration via Password-Reset Endpoint #10727


User avatar
0
murat.kebabci created

A penetration test revealed a high-severity email enumeration vulnerability (CWE-204, OWASP ASVS 4.0.3 §V2.6.7) on the account module's password reset endpoint.

Affected Endpoint:

  • POST /api/account/send-password-reset-code

Issue Details: The endpoint returns differential responses based on whether the supplied email exists in the selected user store.

  • If the email exists: Returns HTTP 500 (e.g., An internal error occurred...)
  • If the email does not exist: Returns HTTP 403 (e.g., Can not find the given email address...)

Additionally, passing a __tenant: header allows the lookup to pivot against any named tenant's user store without rate limits. We observed that the preventEmailEnumeration setting under /api/account-admin/settings appears to be false.

Requested Assistance & Remediation Guidance: We need to configure the password-reset action to always return an HTTP 200 with a uniform response body, regardless of whether the email exists in the system. How can we properly enforce preventEmailEnumeration: true globally and secure this endpoint against enumeration attacks in our current version?

Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

1 Answer(s)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    Two things going on here, and you need both to fully close the enumeration:

    1. Turn the setting on — covers the "user not found" branch only

    Abp.Account.PreventEmailEnumeration defaults to false. When you turn it on (Admin → Settings → Account → Prevent email enumeration), the "email not found" path silently returns 204 instead of throwing 403. So far so good.

    But the setting only covers the user == null branch in AccountAppService.SendPasswordResetCodeAsync. When the user actually exists, the method goes on to call AccountEmailer.SendPasswordResetLinkAsync, and any exception there (SMTP failure, missing email template, an appName that isn't registered with AppUrlOptions, etc.) bubbles up as HTTP 500 — which is exactly the differential you're seeing. The setting alone won't fix that.

    2. Override AccountAppService.SendPasswordResetCodeAsync to swallow everything

    This is the change that actually makes the response uniform. Put this in your *.HttpApi.Host (or any host that exposes this route):

    using System;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Identity;
    using Microsoft.Extensions.Caching.Distributed;
    using Microsoft.Extensions.Options;
    using Volo.Abp;
    using Volo.Abp.Account;
    using Volo.Abp.Account.Emailing;
    using Volo.Abp.Account.PhoneNumber;
    using Volo.Abp.BlobStoring;
    using Volo.Abp.Caching;
    using Volo.Abp.DependencyInjection;
    using Volo.Abp.Identity;
    using Volo.Abp.Imaging;
    using Volo.Abp.SettingManagement;
    using Volo.Abp.Users;
    
    namespace MyCompanyName.MyProjectName;
    
    [Dependency(ReplaceServices = true)]
    [ExposeServices(typeof(IAccountAppService), typeof(AccountAppService))]
    public class EnumerationProofAccountAppService : AccountAppService
    {
        public EnumerationProofAccountAppService(
            IdentityUserManager userManager,
            IAccountEmailer accountEmailer,
            IAccountPhoneService phoneService,
            IIdentityRoleRepository roleRepository,
            IdentitySecurityLogManager identitySecurityLogManager,
            IBlobContainer<AccountProfilePictureContainer> accountProfilePictureContainer,
            ISettingManager settingManager,
            IOptions<IdentityOptions> identityOptions,
            IIdentitySecurityLogRepository securityLogRepository,
            IImageCompressor imageCompressor,
            IOptions<AbpProfilePictureOptions> profilePictureOptions,
            IApplicationInfoAccessor applicationInfoAccessor,
            IdentityUserTwoFactorChecker identityUserTwoFactorChecker,
            IDistributedCache<EmailConfirmationCodeCacheItem> emailConfirmationCodeCache,
            IdentityErrorDescriber identityErrorDescriber,
            IOptions<AbpRegisterEmailConfirmationCodeOptions> registerEmailConfirmationCodeOptions)
            : base(userManager, accountEmailer, phoneService, roleRepository,
                identitySecurityLogManager, accountProfilePictureContainer, settingManager, identityOptions,
                securityLogRepository, imageCompressor, profilePictureOptions, applicationInfoAccessor,
                identityUserTwoFactorChecker, emailConfirmationCodeCache, identityErrorDescriber,
                registerEmailConfirmationCodeOptions)
        {
        }
    
        public override async Task SendPasswordResetCodeAsync(SendPasswordResetCodeDto input)
        {
            try
            {
                await base.SendPasswordResetCodeAsync(input);
            }
            catch (Exception ex)
            {
                Logger.LogException(ex);
            }
        }
    }
    

    With the setting on and the override in place, every input — existing user, non-existent email, valid appName, invalid appName — returns the same 204 No Content with an empty body. That's what removes the oracle. The same code path is present in current 10.x, so this isn't fixed by upgrading.

    3. About rate limiting

    You're right that for enumeration this endpoint is effectively unlimited. There is a built-in policy in AbpAccountPublicApplicationModule (Account.SendPasswordResetCode, 10/hour and 30/day), but it partitions by email, so an attacker rotating through email candidates never hits it. That policy is meant to stop a single victim getting spammed, not to stop enumeration.

    For enumeration, add a per-IP limit on top. ASP.NET Core's built-in RateLimiter middleware scoped to /api/account/send-password-reset-code, or a rule at your reverse proxy (YARP / nginx / ingress), both work. Anything that buckets by client IP is fine.

    4. About the __tenant header

    Switching tenant context via __tenant is by design for multi-tenant clients, so we can't drop it in the framework. But the override above runs inside the tenant context, so once it's in place the response is uniform regardless of which tenant the attacker pivots into. The remaining tenant-existence oracle (the /api/abp/multi-tenancy/tenants/by-name|by-id endpoints) is the same one you reported in #10725 — the workaround there closes it.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 16, 2026, 14:50
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.