Add IpAddress utility (#2131)

This commit is contained in:
Leendert de Borst
2026-06-04 20:03:58 +02:00
committed by Leendert de Borst
parent f0db97f4c2
commit a6b1cf2705
8 changed files with 419 additions and 73 deletions

View File

@@ -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;

View File

@@ -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<SharedConfig>();
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<SharedConfig>();
var ipAddress = IpAddressUtility.GetIpFromContext(httpContext, config?.IpLoggingEnabled == true);
var ipAddress = IpAddressUtility.GetAnonymizedIpFromContext(httpContext, config?.IpLoggingEnabled == true);
var authAttempt = new AuthLog
{

View File

@@ -0,0 +1,92 @@
//-----------------------------------------------------------------------
// <copyright file="IpAddressUtility.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Auth.IpAddress;
using System.Net;
using Microsoft.AspNetCore.Http;
/// <summary>
/// Ip address utility class to extract IP address from HttpContext.
/// </summary>
public static class IpAddressUtility
{
/// <summary>
/// Fully anonymized IP address constant used when IP logging is disabled.
/// </summary>
public const string AnonymizedIp = "xxx.xxx.xxx.xxx";
/// <summary>
/// Extracts the anonymized IP address (IPv4 last octet masked) from the HttpContext for persistence/logging.
/// </summary>
/// <param name="httpContext">HttpContext to extract the IP address from.</param>
/// <param name="ipLoggingEnabled">Whether IP logging is enabled. If false, returns fully anonymized IP.</param>
/// <returns>Anonymized IP address.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="httpContext">HttpContext to extract the IP address from.</param>
/// <returns>The parsed IP address, or null when it cannot be determined.</returns>
public static IPAddress? GetRawIpAddressFromContext(HttpContext? httpContext)
{
if (httpContext == null)
{
return null;
}
return IPAddress.TryParse(ExtractRawIpString(httpContext), out var parsed) ? parsed : null;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="httpContext">HttpContext to extract the IP address from.</param>
/// <returns>The raw IP address string, or null when it cannot be determined.</returns>
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();
}
}

View File

@@ -0,0 +1,65 @@
//-----------------------------------------------------------------------
// <copyright file="IpBlockEvaluator.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Auth.IpAddress;
using System.Collections.Generic;
using System.Net;
using AliasServerDb;
using AliasVault.Auth.IpAddress.Models;
/// <summary>
/// Evaluation of whether an IP address is blocked for a given action, given a set of
/// blocklist ranges.
/// </summary>
public static class IpBlockEvaluator
{
/// <summary>
/// Determines whether the given address is blocked for the given action by any of the supplied ranges.
/// </summary>
/// <param name="ranges">The candidate blocklist ranges.</param>
/// <param name="address">The IP address to evaluate.</param>
/// <param name="action">The action to evaluate.</param>
/// <returns>True if the address is blocked for the action, false otherwise.</returns>
public static bool IsBlocked(IEnumerable<BlockedIpRange> 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;
}
/// <summary>
/// Returns whether the range covers the given action.
/// </summary>
/// <param name="range">The blocklist range.</param>
/// <param name="action">The action to evaluate.</param>
/// <returns>True if the range guards the action.</returns>
private static bool HasFlag(BlockedIpRange range, IpBlockAction action) => action switch
{
IpBlockAction.Registration => range.BlockRegistration,
IpBlockAction.Login => range.BlockLogin,
IpBlockAction.Shadow => range.BlockShadow,
_ => false,
};
}

View File

@@ -0,0 +1,68 @@
//-----------------------------------------------------------------------
// <copyright file="IpBlockListService.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Auth.IpAddress;
using System.Net;
using System.Threading.Tasks;
using AliasServerDb;
using AliasVault.Auth.IpAddress.Models;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Service for checking whether an IP address is on the IP blocklist.
/// </summary>
/// <param name="dbContextFactory">IDbContextFactory instance.</param>
public class IpBlockListService(IAliasServerDbContextFactory dbContextFactory)
{
/// <summary>
/// Determines whether the given IP address is blocked from registering a new account.
/// </summary>
/// <param name="ipAddress">The IP address to evaluate.</param>
/// <returns>True if the IP is blocked for registration, false otherwise.</returns>
public Task<bool> IsBlockedForRegistrationAsync(IPAddress? ipAddress)
=> IsBlockedAsync(ipAddress, IpBlockAction.Registration);
/// <summary>
/// Determines whether the given IP address is blocked for login / general access.
/// </summary>
/// <param name="ipAddress">The IP address to evaluate.</param>
/// <returns>True if the IP is blocked for login, false otherwise.</returns>
public Task<bool> IsBlockedForLoginAsync(IPAddress? ipAddress)
=> IsBlockedAsync(ipAddress, IpBlockAction.Login);
/// <summary>
/// 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.
/// </summary>
/// <param name="ipAddress">The IP address to evaluate.</param>
/// <returns>True if the IP is shadow-blocked for email retrieval, false otherwise.</returns>
public Task<bool> IsBlockedForEmailsAsync(IPAddress? ipAddress)
=> IsBlockedAsync(ipAddress, IpBlockAction.Shadow);
/// <summary>
/// Loads all enabled ranges and evaluates whether the IP is blocked for the given action.
/// </summary>
/// <param name="ipAddress">The IP address to evaluate.</param>
/// <param name="action">The action to evaluate.</param>
/// <returns>True if the IP is blocked for the action, false otherwise.</returns>
private async Task<bool> 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);
}
}

View File

@@ -0,0 +1,161 @@
//-----------------------------------------------------------------------
// <copyright file="IpRangeUtility.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Auth.IpAddress;
using System.Net;
using System.Net.Sockets;
/// <summary>
/// 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.
/// </summary>
public static class IpRangeUtility
{
/// <summary>
/// Attempts to parse a CIDR range (or bare IP address) into a normalized <see cref="IPNetwork"/>.
/// 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".
/// </summary>
/// <param name="input">The CIDR range or bare IP address to parse.</param>
/// <param name="network">The resulting normalized network when parsing succeeds.</param>
/// <returns>True if the input is a valid CIDR range or bare IP address, false otherwise.</returns>
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;
}
}
/// <summary>
/// Determines whether the provided input is a valid CIDR range or bare IP address.
/// </summary>
/// <param name="input">The value to validate.</param>
/// <returns>True if valid, false otherwise.</returns>
public static bool IsValid(string? input) => TryParse(input, out _);
/// <summary>
/// Normalizes a CIDR range (or bare IP address) to its canonical "base/prefix" string representation.
/// </summary>
/// <param name="input">The value to normalize.</param>
/// <returns>The normalized CIDR string, or null when the input is invalid.</returns>
public static string? Normalize(string? input)
{
return TryParse(input, out var network) ? network.ToString() : null;
}
/// <summary>
/// Determines whether the given IP address falls within the given network.
/// </summary>
/// <param name="network">The network to test against.</param>
/// <param name="address">The IP address to test.</param>
/// <returns>True if the address is contained in the network, false otherwise.</returns>
public static bool Contains(IPNetwork network, IPAddress? address)
{
if (address is null)
{
return false;
}
var normalized = NormalizeAddress(address);
return network.Contains(normalized);
}
/// <summary>
/// Unwraps an IPv4-mapped IPv6 address into its native IPv4 form.
/// </summary>
/// <param name="address">The address to normalize.</param>
/// <returns>The normalized IP address.</returns>
public static IPAddress NormalizeAddress(IPAddress address)
{
return address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
}
/// <summary>
/// Masks the address to the network base.
/// </summary>
/// <param name="address">The address to mask.</param>
/// <param name="prefixLength">The network prefix length.</param>
/// <returns>The masked (network base) address.</returns>
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);
}
}

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="IpBlockAction.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Auth.IpAddress.Models;
/// <summary>
/// The action that an IP blocklist rule can guard against.
/// </summary>
public enum IpBlockAction
{
/// <summary>
/// Creating a new account.
/// </summary>
Registration,
/// <summary>
/// Logging in / general access.
/// </summary>
Login,
/// <summary>
/// Shadow-block (email alias usage).
/// </summary>
Shadow,
}

View File

@@ -1,70 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="IpAddressUtility.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Auth;
using Microsoft.AspNetCore.Http;
/// <summary>
/// Ip address utility class to extract IP address from HttpContext.
/// </summary>
public static class IpAddressUtility
{
/// <summary>
/// Fully anonymized IP address constant used when IP logging is disabled.
/// </summary>
public const string AnonymizedIp = "xxx.xxx.xxx.xxx";
/// <summary>
/// Extract IP address from HttpContext.
/// </summary>
/// <param name="httpContext">HttpContext to extract the IP address from.</param>
/// <param name="ipLoggingEnabled">Whether IP logging is enabled. If false, returns fully anonymized IP.</param>
/// <returns>Ip address.</returns>
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;
}
}