diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs index 5b3f7176c..426f0b718 100644 --- a/apps/server/AliasVault.Admin/Services/StatisticsService.cs +++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs @@ -9,7 +9,7 @@ namespace AliasVault.Admin.Services; using AliasServerDb; using AliasVault.Admin.Main.Models; -using AliasVault.Auth; +using AliasVault.Auth.IpAddress; using AliasVault.Shared.Models.Enums; using Microsoft.EntityFrameworkCore; diff --git a/apps/server/Utilities/AliasVault.Auth/AuthLoggingService.cs b/apps/server/Utilities/AliasVault.Auth/AuthLoggingService.cs index 5d8208fd6..ba1698299 100644 --- a/apps/server/Utilities/AliasVault.Auth/AuthLoggingService.cs +++ b/apps/server/Utilities/AliasVault.Auth/AuthLoggingService.cs @@ -8,6 +8,7 @@ namespace AliasVault.Auth; using AliasServerDb; +using AliasVault.Auth.IpAddress; using AliasVault.Shared.Models.Configuration; using AliasVault.Shared.Models.Enums; using Microsoft.AspNetCore.Http; @@ -35,7 +36,7 @@ public class AuthLoggingService(IServiceProvider serviceProvider, IHttpContextAc var clientHeader = httpContext?.Request.Headers["X-AliasVault-Client"].FirstOrDefault(); var config = scope.ServiceProvider.GetRequiredService(); - var ipAddress = IpAddressUtility.GetIpFromContext(httpContext, config.IpLoggingEnabled); + var ipAddress = IpAddressUtility.GetAnonymizedIpFromContext(httpContext, config.IpLoggingEnabled); var authAttempt = new AuthLog { @@ -81,7 +82,7 @@ public class AuthLoggingService(IServiceProvider serviceProvider, IHttpContextAc var clientHeader = httpContext?.Request.Headers["X-AliasVault-Client"].FirstOrDefault(); var config = httpContext?.RequestServices.GetService(); - var ipAddress = IpAddressUtility.GetIpFromContext(httpContext, config?.IpLoggingEnabled == true); + var ipAddress = IpAddressUtility.GetAnonymizedIpFromContext(httpContext, config?.IpLoggingEnabled == true); var authAttempt = new AuthLog { diff --git a/apps/server/Utilities/AliasVault.Auth/IpAddress/IpAddressUtility.cs b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpAddressUtility.cs new file mode 100644 index 000000000..c08f4a13f --- /dev/null +++ b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpAddressUtility.cs @@ -0,0 +1,92 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Auth.IpAddress; + +using System.Net; +using Microsoft.AspNetCore.Http; + +/// +/// Ip address utility class to extract IP address from HttpContext. +/// +public static class IpAddressUtility +{ + /// + /// Fully anonymized IP address constant used when IP logging is disabled. + /// + public const string AnonymizedIp = "xxx.xxx.xxx.xxx"; + + /// + /// Extracts the anonymized IP address (IPv4 last octet masked) from the HttpContext for persistence/logging. + /// + /// HttpContext to extract the IP address from. + /// Whether IP logging is enabled. If false, returns fully anonymized IP. + /// Anonymized IP address. + public static string GetAnonymizedIpFromContext(HttpContext? httpContext, bool ipLoggingEnabled = true) + { + if (!ipLoggingEnabled) + { + return AnonymizedIp; + } + + if (httpContext == null) + { + return string.Empty; + } + + var ipAddress = ExtractRawIpString(httpContext) ?? "0.0.0.0"; + + // Anonymize the last octet of the IP address (IPv4 only). + if (ipAddress.Contains('.')) + { + try + { + var parts = ipAddress.Split('.'); + ipAddress = parts[0] + "." + parts[1] + "." + parts[2] + ".xxx"; + } + catch + { + // If an exception occurs, continue execution with original IP address. + } + } + + return ipAddress; + } + + /// + /// Extracts the raw, non-anonymized IP address from the HttpContext for transient, request-time use only + /// (e.g. matching against the IP blocklist). The returned value is intentionally NOT anonymized and must + /// never be persisted. Use GetAnonymizedIpFromContext for persistence/logging instead. + /// + /// HttpContext to extract the IP address from. + /// The parsed IP address, or null when it cannot be determined. + public static IPAddress? GetRawIpAddressFromContext(HttpContext? httpContext) + { + if (httpContext == null) + { + return null; + } + + return IPAddress.TryParse(ExtractRawIpString(httpContext), out var parsed) ? parsed : null; + } + + /// + /// Extracts the raw IP address string from the request, honoring the X-Forwarded-For header (first entry) when + /// present and otherwise falling back to the connection's remote IP address. + /// + /// HttpContext to extract the IP address from. + /// The raw IP address string, or null when it cannot be determined. + private static string? ExtractRawIpString(HttpContext httpContext) + { + if (httpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var xForwardedFor)) + { + return xForwardedFor.ToString().Split(',')[0].Trim(); + } + + return httpContext.Connection.RemoteIpAddress?.ToString(); + } +} diff --git a/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockEvaluator.cs b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockEvaluator.cs new file mode 100644 index 000000000..83565677b --- /dev/null +++ b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockEvaluator.cs @@ -0,0 +1,65 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Auth.IpAddress; + +using System.Collections.Generic; +using System.Net; +using AliasServerDb; +using AliasVault.Auth.IpAddress.Models; + +/// +/// Evaluation of whether an IP address is blocked for a given action, given a set of +/// blocklist ranges. +/// +public static class IpBlockEvaluator +{ + /// + /// Determines whether the given address is blocked for the given action by any of the supplied ranges. + /// + /// The candidate blocklist ranges. + /// The IP address to evaluate. + /// The action to evaluate. + /// True if the address is blocked for the action, false otherwise. + public static bool IsBlocked(IEnumerable ranges, IPAddress? address, IpBlockAction action) + { + if (address is null) + { + return false; + } + + foreach (var range in ranges) + { + if (!HasFlag(range, action)) + { + // This range does not cover the requested action; keep checking the others (overlapping rules). + continue; + } + + if (IpRangeUtility.TryParse(range.IpRange, out var network) && IpRangeUtility.Contains(network, address)) + { + return true; + } + } + + return false; + } + + /// + /// Returns whether the range covers the given action. + /// + /// The blocklist range. + /// The action to evaluate. + /// True if the range guards the action. + private static bool HasFlag(BlockedIpRange range, IpBlockAction action) => action switch + { + IpBlockAction.Registration => range.BlockRegistration, + IpBlockAction.Login => range.BlockLogin, + IpBlockAction.Shadow => range.BlockShadow, + _ => false, + }; +} diff --git a/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockListService.cs b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockListService.cs new file mode 100644 index 000000000..b8ec214be --- /dev/null +++ b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpBlockListService.cs @@ -0,0 +1,68 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Auth.IpAddress; + +using System.Net; +using System.Threading.Tasks; +using AliasServerDb; +using AliasVault.Auth.IpAddress.Models; +using Microsoft.EntityFrameworkCore; + +/// +/// Service for checking whether an IP address is on the IP blocklist. +/// +/// IDbContextFactory instance. +public class IpBlockListService(IAliasServerDbContextFactory dbContextFactory) +{ + /// + /// Determines whether the given IP address is blocked from registering a new account. + /// + /// The IP address to evaluate. + /// True if the IP is blocked for registration, false otherwise. + public Task IsBlockedForRegistrationAsync(IPAddress? ipAddress) + => IsBlockedAsync(ipAddress, IpBlockAction.Registration); + + /// + /// Determines whether the given IP address is blocked for login / general access. + /// + /// The IP address to evaluate. + /// True if the IP is blocked for login, false otherwise. + public Task IsBlockedForLoginAsync(IPAddress? ipAddress) + => IsBlockedAsync(ipAddress, IpBlockAction.Login); + + /// + /// Determines whether the given IP address is shadow-blocked for email retrieval. When true, callers should + /// return an empty result rather than an explicit error. + /// + /// The IP address to evaluate. + /// True if the IP is shadow-blocked for email retrieval, false otherwise. + public Task IsBlockedForEmailsAsync(IPAddress? ipAddress) + => IsBlockedAsync(ipAddress, IpBlockAction.Shadow); + + /// + /// Loads all enabled ranges and evaluates whether the IP is blocked for the given action. + /// + /// The IP address to evaluate. + /// The action to evaluate. + /// True if the IP is blocked for the action, false otherwise. + private async Task IsBlockedAsync(IPAddress? ipAddress, IpBlockAction action) + { + if (ipAddress is null) + { + return false; + } + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + + var enabledRanges = await dbContext.BlockedIpRanges + .Where(x => x.Enabled) + .ToListAsync(); + + return IpBlockEvaluator.IsBlocked(enabledRanges, ipAddress, action); + } +} diff --git a/apps/server/Utilities/AliasVault.Auth/IpAddress/IpRangeUtility.cs b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpRangeUtility.cs new file mode 100644 index 000000000..ec2e15e62 --- /dev/null +++ b/apps/server/Utilities/AliasVault.Auth/IpAddress/IpRangeUtility.cs @@ -0,0 +1,161 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Auth.IpAddress; + +using System.Net; +using System.Net.Sockets; + +/// +/// Utility for parsing, validating, normalizing and matching IP address ranges. +/// Supports IPv4 and IPv6. A bare IP address (without a prefix) is treated as a single host. +/// +public static class IpRangeUtility +{ + /// + /// Attempts to parse a CIDR range (or bare IP address) into a normalized . + /// Host bits beyond the prefix length are masked off, so "1.2.3.4/24" is accepted and normalized to "1.2.3.0/24". + /// + /// The CIDR range or bare IP address to parse. + /// The resulting normalized network when parsing succeeds. + /// True if the input is a valid CIDR range or bare IP address, false otherwise. + public static bool TryParse(string? input, out IPNetwork network) + { + network = default; + + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + var trimmed = input.Trim(); + var parts = trimmed.Split('/'); + if (parts.Length > 2) + { + return false; + } + + var addressPart = parts[0].Trim(); + if (!IPAddress.TryParse(addressPart, out var address)) + { + return false; + } + + // Validate the address part is a valid IP address. + if (!addressPart.Contains(':') && addressPart.Split('.').Length != 4) + { + return false; + } + + var maxPrefix = address.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; + + int prefixLength; + if (parts.Length == 2) + { + if (!int.TryParse(parts[1].Trim(), out prefixLength) || prefixLength < 0 || prefixLength > maxPrefix) + { + return false; + } + } + else + { + // Bare address: treat as a single host. + prefixLength = maxPrefix; + } + + var baseAddress = MaskAddress(address, prefixLength); + + try + { + network = new IPNetwork(baseAddress, prefixLength); + return true; + } + catch (ArgumentException) + { + return false; + } + } + + /// + /// Determines whether the provided input is a valid CIDR range or bare IP address. + /// + /// The value to validate. + /// True if valid, false otherwise. + public static bool IsValid(string? input) => TryParse(input, out _); + + /// + /// Normalizes a CIDR range (or bare IP address) to its canonical "base/prefix" string representation. + /// + /// The value to normalize. + /// The normalized CIDR string, or null when the input is invalid. + public static string? Normalize(string? input) + { + return TryParse(input, out var network) ? network.ToString() : null; + } + + /// + /// Determines whether the given IP address falls within the given network. + /// + /// The network to test against. + /// The IP address to test. + /// True if the address is contained in the network, false otherwise. + public static bool Contains(IPNetwork network, IPAddress? address) + { + if (address is null) + { + return false; + } + + var normalized = NormalizeAddress(address); + return network.Contains(normalized); + } + + /// + /// Unwraps an IPv4-mapped IPv6 address into its native IPv4 form. + /// + /// The address to normalize. + /// The normalized IP address. + public static IPAddress NormalizeAddress(IPAddress address) + { + return address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + } + + /// + /// Masks the address to the network base. + /// + /// The address to mask. + /// The network prefix length. + /// The masked (network base) address. + private static IPAddress MaskAddress(IPAddress address, int prefixLength) + { + var bytes = address.GetAddressBytes(); + + for (var i = 0; i < bytes.Length; i++) + { + var bitsForThisByte = prefixLength - (i * 8); + if (bitsForThisByte >= 8) + { + // Fully within the network portion; keep the byte as-is. + continue; + } + + if (bitsForThisByte <= 0) + { + // Fully within the host portion; zero it out. + bytes[i] = 0; + } + else + { + // Partial byte; keep the high 'bitsForThisByte' bits. + var mask = (byte)(0xFF << (8 - bitsForThisByte)); + bytes[i] &= mask; + } + } + + return new IPAddress(bytes); + } +} diff --git a/apps/server/Utilities/AliasVault.Auth/IpAddress/Models/IpBlockAction.cs b/apps/server/Utilities/AliasVault.Auth/IpAddress/Models/IpBlockAction.cs new file mode 100644 index 000000000..9fa4cb58c --- /dev/null +++ b/apps/server/Utilities/AliasVault.Auth/IpAddress/Models/IpBlockAction.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Auth.IpAddress.Models; + +/// +/// The action that an IP blocklist rule can guard against. +/// +public enum IpBlockAction +{ + /// + /// Creating a new account. + /// + Registration, + + /// + /// Logging in / general access. + /// + Login, + + /// + /// Shadow-block (email alias usage). + /// + Shadow, +} diff --git a/apps/server/Utilities/AliasVault.Auth/IpAddressUtility.cs b/apps/server/Utilities/AliasVault.Auth/IpAddressUtility.cs deleted file mode 100644 index 70c535ce0..000000000 --- a/apps/server/Utilities/AliasVault.Auth/IpAddressUtility.cs +++ /dev/null @@ -1,70 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) aliasvault. All rights reserved. -// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. -// -//----------------------------------------------------------------------- - -namespace AliasVault.Auth; - -using Microsoft.AspNetCore.Http; - -/// -/// Ip address utility class to extract IP address from HttpContext. -/// -public static class IpAddressUtility -{ - /// - /// Fully anonymized IP address constant used when IP logging is disabled. - /// - public const string AnonymizedIp = "xxx.xxx.xxx.xxx"; - - /// - /// Extract IP address from HttpContext. - /// - /// HttpContext to extract the IP address from. - /// Whether IP logging is enabled. If false, returns fully anonymized IP. - /// Ip address. - public static string GetIpFromContext(HttpContext? httpContext, bool ipLoggingEnabled = true) - { - if (!ipLoggingEnabled) - { - return AnonymizedIp; - } - - string ipAddress = string.Empty; - - if (httpContext == null) - { - return ipAddress; - } - - if (string.IsNullOrEmpty(ipAddress)) - { - // Check if X-Forwarded-For header exists, if so, extract first IP address from comma separated list. - if (httpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var xForwardedFor)) - { - ipAddress = xForwardedFor.ToString().Split(',')[0]; - } - else - { - ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0"; - } - } - - // Anonymize the last octet of the IP address. - if (ipAddress.Contains('.')) - { - try - { - ipAddress = ipAddress.Split('.')[0] + "." + ipAddress.Split('.')[1] + "." + ipAddress.Split('.')[2] + ".xxx"; - } - catch - { - // If an exception occurs, continue execution with original IP address. - } - } - - return ipAddress; - } -}