mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-08-02 10:18:41 -04:00
Add IpAddress utility (#2131)
This commit is contained in:
committed by
Leendert de Borst
parent
f0db97f4c2
commit
a6b1cf2705
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user