Update shadow block checks (#2131)

This commit is contained in:
Leendert de Borst
2026-06-04 20:42:23 +02:00
committed by Leendert de Borst
parent b6a1880769
commit b6d7fcc1be
3 changed files with 28 additions and 37 deletions

View File

@@ -23,7 +23,7 @@ using Microsoft.EntityFrameworkCore;
/// </summary>
/// <param name="dbContextFactory">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="ipBlockListService">IpBlockListService used to shadow-ban email retrieval from blocked IPs.</param>
/// <param name="ipBlockListService">IpBlockListService used to shadow-block email retrieval from blocked IPs.</param>
[ApiVersion("1")]
public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager, IpBlockListService ipBlockListService) : AuthenticatedRequestController(userManager)
{
@@ -43,11 +43,8 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U
return Unauthorized("Not authenticated.");
}
// Shadow-blocked: a shadow-blocked account or blocked IP gets an empty mailbox rather than an explicit error.
if (await IsEmailRetrievalShadowBlockedAsync(user))
{
return Ok(new MailboxApiModel { Address = to, Subscribed = false, Mails = [] });
}
// Shadow-block: when active, only emails received before the block took effect are visible.
var shadowCutoff = await ipBlockListService.GetShadowBlockCutoffAsync(user, IpAddressUtility.GetRawIpAddressFromContext(HttpContext));
var sanitizedEmail = to.Trim().ToLower();
@@ -79,9 +76,14 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U
});
}
// Retrieve emails from database.
List<MailboxEmailApiModel> emails = await context.Emails.AsNoTracking()
.Where(x => x.To == sanitizedEmail)
// Retrieve emails from database (excluding any received after a shadow-block took effect).
var emailQuery = context.Emails.AsNoTracking().Where(x => x.To == sanitizedEmail);
if (shadowCutoff is not null)
{
emailQuery = emailQuery.Where(x => x.DateSystem <= shadowCutoff.Value);
}
List<MailboxEmailApiModel> emails = await emailQuery
.Select(x => new MailboxEmailApiModel()
{
Id = x.Id,
@@ -128,18 +130,8 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U
return Unauthorized("Not authenticated.");
}
// Shadow-blocked: a shadow-blocked account or blocked IP gets an empty result rather than an explicit error.
if (await IsEmailRetrievalShadowBlockedAsync(user))
{
return Ok(new MailboxBulkResponse
{
Addresses = [],
Mails = [],
PageSize = Math.Min(model.PageSize, 50),
CurrentPage = model.Page,
TotalRecords = 0,
});
}
// Shadow-block: when active, only emails received before the block took effect are visible.
var shadowCutoff = await ipBlockListService.GetShadowBlockCutoffAsync(user, IpAddressUtility.GetRawIpAddressFromContext(HttpContext));
// Sanitize input.
model.Addresses = model.Addresses.Select(x => x.Trim().ToLower()).ToList();
@@ -155,6 +147,11 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U
.AsNoTracking()
.Where(email => validAddresses.Contains(email.To));
if (shadowCutoff is not null)
{
query = query.Where(email => email.DateSystem <= shadowCutoff.Value);
}
// Get all emails for the valid addresses.
var rows = await query
.OrderByDescending(x => x.DateSystem)
@@ -196,13 +193,4 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U
return Ok(returnValue);
}
/// <summary>
/// Determines whether email retrieval for the current request should be shadow-blocked, either because the
/// account is shadow-blocked or because the request originates from a shadow-blocked IP range.
/// </summary>
/// <param name="user">The authenticated user.</param>
/// <returns>True if email retrieval should return an empty result.</returns>
private async Task<bool> IsEmailRetrievalShadowBlockedAsync(AliasVaultUser user)
=> user.ShadowBlocked || await ipBlockListService.IsBlockedForEmailsAsync(IpAddressUtility.GetRawIpAddressFromContext(HttpContext));
}

View File

@@ -153,11 +153,8 @@ public class EmailController(ILogger<VaultController> logger, IAliasServerDbCont
return (null, Unauthorized("Not authenticated."));
}
// Shadow-blocked: a shadow-blocked account or blocked IP behaves as if the email does not exist.
if (user.ShadowBlocked || await ipBlockListService.IsBlockedForEmailsAsync(IpAddressUtility.GetRawIpAddressFromContext(HttpContext)))
{
return (null, NotFound());
}
// Shadow-block: when active, emails received after the block took effect behave as if they do not exist.
var shadowCutoff = await ipBlockListService.GetEmailShadowBlockCutoffAsync(user, IpAddressUtility.GetRawIpAddressFromContext(HttpContext));
// Retrieve email from database.
var email = await context.Emails
@@ -169,6 +166,12 @@ public class EmailController(ILogger<VaultController> logger, IAliasServerDbCont
return (null, NotFound("Email not found."));
}
// Hide emails received after a shadow-block took effect.
if (shadowCutoff is not null && email.DateSystem > shadowCutoff.Value)
{
return (null, NotFound());
}
// See if this user has a valid claim to the email address.
var normalizedEmailAddress = email.To.Trim().ToLower();
var emailClaim = await context.UserEmailClaims.FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == normalizedEmailAddress && !x.Disabled);

View File

@@ -41,12 +41,12 @@ public class IpBlockListService(IAliasServerDbContextFactory dbContextFactory)
/// <param name="user">The authenticated user.</param>
/// <param name="ipAddress">The IP address to evaluate.</param>
/// <returns>The earliest shadow-block timestamp, or null when not shadow-blocked.</returns>
public async Task<DateTime?> GetEmailShadowBlockCutoffAsync(AliasVaultUser user, IPAddress? ipAddress)
public async Task<DateTime?> GetShadowBlockCutoffAsync(AliasVaultUser user, IPAddress? ipAddress)
{
// Account-level shadow-block. When the timestamp is unknown, return min timestamp.
DateTime? cutoff = user.ShadowBlocked ? (user.ShadowBlockedAt ?? DateTime.UnixEpoch) : null;
// IP-range shadow-block: the earliest matching block determines the cutoff (the most that should be hidden).
// IP-range shadow-block: the earliest matching block determines the cutoff time.
if (ipAddress is not null)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync();