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?
1 Answer(s)
-
0
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.PreventEmailEnumerationdefaults tofalse. 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 == nullbranch inAccountAppService.SendPasswordResetCodeAsync. When the user actually exists, the method goes on to callAccountEmailer.SendPasswordResetLinkAsync, and any exception there (SMTP failure, missing email template, anappNamethat isn't registered withAppUrlOptions, etc.) bubbles up as HTTP 500 — which is exactly the differential you're seeing. The setting alone won't fix that.2. Override
AccountAppService.SendPasswordResetCodeAsyncto swallow everythingThis 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, invalidappName— returns the same204 No Contentwith 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
RateLimitermiddleware 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
__tenantheaderSwitching tenant context via
__tenantis 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-idendpoints) 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)