diff --git a/README.md b/README.md
index 959f354..0ace6ab 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,8 @@ or in `appsettings.json` file:
### ClientIp
`ClientIp` enricher reads client IP from `HttpContext.Connection.RemoteIpAddress`. Since version 2.1, for [security reasons](https://nvd.nist.gov/vuln/detail/CVE-2023-22474), it no longer reads the `x-forwarded-for` header. To handle forwarded headers, configure [ForwardedHeadersOptions](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-7.0#forwarded-headers-middleware-order). If you still want to log `x-forwarded-for`, you can use the `RequestHeader` enricher.
+
+#### Basic Usage
```csharp
Log.Logger = new LoggerConfiguration()
.Enrich.WithClientIp()
@@ -67,6 +69,22 @@ or
}
}
```
+
+#### IP Version Preferences
+You can configure the enricher to prefer or filter specific IP versions (IPv4 or IPv6):
+
+```csharp
+Log.Logger = new LoggerConfiguration()
+ .Enrich.WithClientIp(IpVersionPreference.Ipv4Only)
+ ...
+```
+
+Available IP version preferences:
+- `None` (default): No preference - use whatever IP version is available
+- `PreferIpv4`: Prefer IPv4 addresses when multiple are available, fallback to IPv6
+- `PreferIpv6`: Prefer IPv6 addresses when multiple are available, fallback to IPv4
+- `Ipv4Only`: Only log IPv4 addresses, ignore IPv6 addresses
+- `Ipv6Only`: Only log IPv6 addresses, ignore IPv4 addresses
### CorrelationId
For `CorrelationId` enricher you can:
- Configure the header name and default header name is `x-correlation-id`
diff --git a/src/Serilog.Enrichers.ClientInfo/Enrichers/ClientIpEnricher.cs b/src/Serilog.Enrichers.ClientInfo/Enrichers/ClientIpEnricher.cs
index b29f80c..2c78f91 100644
--- a/src/Serilog.Enrichers.ClientInfo/Enrichers/ClientIpEnricher.cs
+++ b/src/Serilog.Enrichers.ClientInfo/Enrichers/ClientIpEnricher.cs
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Http;
using Serilog.Core;
using Serilog.Events;
+using System.Net;
+using System.Net.Sockets;
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Serilog.Enrichers.ClientInfo.Tests")]
@@ -14,17 +16,27 @@ public class ClientIpEnricher : ILogEventEnricher
private const string IpAddressItemKey = "Serilog_ClientIp";
private readonly IHttpContextAccessor _contextAccessor;
+ private readonly IpVersionPreference _ipVersionPreference;
///
/// Initializes a new instance of the class.
///
- public ClientIpEnricher() : this(new HttpContextAccessor())
+ public ClientIpEnricher() : this(new HttpContextAccessor(), IpVersionPreference.None)
{
}
- internal ClientIpEnricher(IHttpContextAccessor contextAccessor)
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The IP version preference for filtering IP addresses.
+ public ClientIpEnricher(IpVersionPreference ipVersionPreference) : this(new HttpContextAccessor(), ipVersionPreference)
+ {
+ }
+
+ internal ClientIpEnricher(IHttpContextAccessor contextAccessor, IpVersionPreference ipVersionPreference = IpVersionPreference.None)
{
_contextAccessor = contextAccessor;
+ _ipVersionPreference = ipVersionPreference;
}
///
@@ -36,7 +48,20 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
return;
}
- var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ var remoteIpAddress = httpContext.Connection.RemoteIpAddress;
+ if (remoteIpAddress == null)
+ {
+ return;
+ }
+
+ // Apply IP version filtering based on preference
+ var filteredIpAddress = ApplyIpVersionFilter(remoteIpAddress);
+ if (filteredIpAddress == null)
+ {
+ return; // IP address was filtered out based on preference
+ }
+
+ var ipAddress = filteredIpAddress.ToString();
if (httpContext.Items[IpAddressItemKey] is LogEventProperty logEventProperty)
{
@@ -53,4 +78,22 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
httpContext.Items.Add(IpAddressItemKey, ipAddressProperty);
logEvent.AddPropertyIfAbsent(ipAddressProperty);
}
+
+ ///
+ /// Applies IP version filtering based on the configured preference.
+ ///
+ /// The IP address to filter.
+ /// The filtered IP address, or null if it should be excluded.
+ private IPAddress ApplyIpVersionFilter(IPAddress ipAddress)
+ {
+ return _ipVersionPreference switch
+ {
+ IpVersionPreference.None => ipAddress,
+ IpVersionPreference.PreferIpv4 => ipAddress, // For single IP, just return it (preference only matters with multiple IPs)
+ IpVersionPreference.PreferIpv6 => ipAddress, // For single IP, just return it (preference only matters with multiple IPs)
+ IpVersionPreference.Ipv4Only => ipAddress.AddressFamily == AddressFamily.InterNetwork ? ipAddress : null,
+ IpVersionPreference.Ipv6Only => ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? ipAddress : null,
+ _ => ipAddress
+ };
+ }
}
\ No newline at end of file
diff --git a/src/Serilog.Enrichers.ClientInfo/Enrichers/IpVersionPreference.cs b/src/Serilog.Enrichers.ClientInfo/Enrichers/IpVersionPreference.cs
new file mode 100644
index 0000000..27e85c6
--- /dev/null
+++ b/src/Serilog.Enrichers.ClientInfo/Enrichers/IpVersionPreference.cs
@@ -0,0 +1,32 @@
+namespace Serilog.Enrichers;
+
+///
+/// Specifies the IP version preference for client IP enrichment.
+///
+public enum IpVersionPreference
+{
+ ///
+ /// No preference - use whatever IP version is available (default behavior).
+ ///
+ None = 0,
+
+ ///
+ /// Prefer IPv4 addresses when available, fallback to IPv6 if IPv4 is not available.
+ ///
+ PreferIpv4 = 1,
+
+ ///
+ /// Prefer IPv6 addresses when available, fallback to IPv4 if IPv6 is not available.
+ ///
+ PreferIpv6 = 2,
+
+ ///
+ /// Only log IPv4 addresses, ignore IPv6 addresses.
+ ///
+ Ipv4Only = 3,
+
+ ///
+ /// Only log IPv6 addresses, ignore IPv4 addresses.
+ ///
+ Ipv6Only = 4
+}
\ No newline at end of file
diff --git a/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs b/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs
index 6925e1a..1e9e7dc 100644
--- a/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs
+++ b/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs
@@ -24,6 +24,22 @@ public static LoggerConfiguration WithClientIp(
return enrichmentConfiguration.With();
}
+ ///
+ /// Registers the client IP enricher to enrich logs with value.
+ ///
+ /// The enrichment configuration.
+ /// The IP version preference for filtering IP addresses.
+ /// enrichmentConfiguration
+ /// The logger configuration so that multiple calls can be chained.
+ public static LoggerConfiguration WithClientIp(
+ this LoggerEnrichmentConfiguration enrichmentConfiguration,
+ IpVersionPreference ipVersionPreference)
+ {
+ ArgumentNullException.ThrowIfNull(enrichmentConfiguration, nameof(enrichmentConfiguration));
+
+ return enrichmentConfiguration.With(new ClientIpEnricher(ipVersionPreference));
+ }
+
///
/// Registers the correlation id enricher to enrich logs with correlation id with
/// 'x-correlation-id' header information.
diff --git a/src/Serilog.Enrichers.ClientInfo/Serilog.Enrichers.ClientInfo.csproj b/src/Serilog.Enrichers.ClientInfo/Serilog.Enrichers.ClientInfo.csproj
index a06cc56..1dbbf2a 100644
--- a/src/Serilog.Enrichers.ClientInfo/Serilog.Enrichers.ClientInfo.csproj
+++ b/src/Serilog.Enrichers.ClientInfo/Serilog.Enrichers.ClientInfo.csproj
@@ -1,6 +1,6 @@
- net8.0;net9.0
+ net8.0
Serilog.Enrichers.ClientInfo
Serilog
latest
diff --git a/test/Serilog.Enrichers.ClientInfo.Tests/ClientIpEnricherIpVersionPreferenceTests.cs b/test/Serilog.Enrichers.ClientInfo.Tests/ClientIpEnricherIpVersionPreferenceTests.cs
new file mode 100644
index 0000000..dee4d46
--- /dev/null
+++ b/test/Serilog.Enrichers.ClientInfo.Tests/ClientIpEnricherIpVersionPreferenceTests.cs
@@ -0,0 +1,210 @@
+using Microsoft.AspNetCore.Http;
+using NSubstitute;
+using Serilog.Events;
+using System.Net;
+using System.Net.Sockets;
+using Xunit;
+
+namespace Serilog.Enrichers.ClientInfo.Tests;
+
+public class ClientIpEnricherIpVersionPreferenceTests
+{
+ private readonly IHttpContextAccessor _contextAccessor;
+
+ public ClientIpEnricherIpVersionPreferenceTests()
+ {
+ var httpContext = new DefaultHttpContext();
+ _contextAccessor = Substitute.For();
+ _contextAccessor.HttpContext.Returns(httpContext);
+ }
+
+ [Theory]
+ [InlineData("192.168.1.1", IpVersionPreference.None, "192.168.1.1")]
+ [InlineData("::1", IpVersionPreference.None, "::1")]
+ [InlineData("192.168.1.1", IpVersionPreference.PreferIpv4, "192.168.1.1")]
+ [InlineData("::1", IpVersionPreference.PreferIpv6, "::1")]
+ public void EnrichLogWithClientIp_WithNoneOrPreferenceForSingleIp_ShouldLogTheIp(string ip, IpVersionPreference preference, string expectedIp)
+ {
+ // Arrange
+ var ipAddress = IPAddress.Parse(ip);
+ _contextAccessor.HttpContext!.Connection.RemoteIpAddress = ipAddress;
+
+ var ipEnricher = new ClientIpEnricher(_contextAccessor, preference);
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information(@"Has an IP property");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey("ClientIp"));
+ Assert.Equal(expectedIp, evt.Properties["ClientIp"].LiteralValue());
+ }
+
+ [Theory]
+ [InlineData("192.168.1.1", IpVersionPreference.Ipv4Only)]
+ [InlineData("127.0.0.1", IpVersionPreference.Ipv4Only)]
+ public void EnrichLogWithClientIp_WithIpv4OnlyPreferenceAndIpv4Address_ShouldLogTheIp(string ip, IpVersionPreference preference)
+ {
+ // Arrange
+ var ipAddress = IPAddress.Parse(ip);
+ _contextAccessor.HttpContext!.Connection.RemoteIpAddress = ipAddress;
+
+ var ipEnricher = new ClientIpEnricher(_contextAccessor, preference);
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information(@"Has an IP property");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey("ClientIp"));
+ Assert.Equal(ip, evt.Properties["ClientIp"].LiteralValue());
+ }
+
+ [Theory]
+ [InlineData("::1", IpVersionPreference.Ipv6Only)]
+ [InlineData("2001:0db8:85a3:0000:0000:8a2e:0370:7334", IpVersionPreference.Ipv6Only)]
+ public void EnrichLogWithClientIp_WithIpv6OnlyPreferenceAndIpv6Address_ShouldLogTheIp(string ip, IpVersionPreference preference)
+ {
+ // Arrange
+ var ipAddress = IPAddress.Parse(ip);
+ _contextAccessor.HttpContext!.Connection.RemoteIpAddress = ipAddress;
+
+ var ipEnricher = new ClientIpEnricher(_contextAccessor, preference);
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information(@"Has an IP property");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey("ClientIp"));
+ // Compare the parsed IP address toString result directly instead of the input string
+ Assert.Equal(ipAddress.ToString(), evt.Properties["ClientIp"].LiteralValue());
+ }
+
+ [Theory]
+ [InlineData("::1", IpVersionPreference.Ipv4Only)]
+ [InlineData("2001:0db8:85a3:0000:0000:8a2e:0370:7334", IpVersionPreference.Ipv4Only)]
+ public void EnrichLogWithClientIp_WithIpv4OnlyPreferenceAndIpv6Address_ShouldNotLogIp(string ip, IpVersionPreference preference)
+ {
+ // Arrange
+ var ipAddress = IPAddress.Parse(ip);
+ _contextAccessor.HttpContext!.Connection.RemoteIpAddress = ipAddress;
+
+ var ipEnricher = new ClientIpEnricher(_contextAccessor, preference);
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information(@"Has an IP property");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.False(evt.Properties.ContainsKey("ClientIp"));
+ }
+
+ [Theory]
+ [InlineData("192.168.1.1", IpVersionPreference.Ipv6Only)]
+ [InlineData("127.0.0.1", IpVersionPreference.Ipv6Only)]
+ public void EnrichLogWithClientIp_WithIpv6OnlyPreferenceAndIpv4Address_ShouldNotLogIp(string ip, IpVersionPreference preference)
+ {
+ // Arrange
+ var ipAddress = IPAddress.Parse(ip);
+ _contextAccessor.HttpContext!.Connection.RemoteIpAddress = ipAddress;
+
+ var ipEnricher = new ClientIpEnricher(_contextAccessor, preference);
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information(@"Has an IP property");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.False(evt.Properties.ContainsKey("ClientIp"));
+ }
+
+ [Fact]
+ public void WithClientIp_WithIpVersionPreference_ShouldNotThrowException()
+ {
+ // Arrange
+ var logger = new LoggerConfiguration()
+ .Enrich.WithClientIp(IpVersionPreference.Ipv4Only)
+ .WriteTo.Sink(new DelegatingSink(_ => { }))
+ .CreateLogger();
+
+ // Act
+ var exception = Record.Exception(() => logger.Information("LOG"));
+
+ // Assert
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void ClientIpEnricher_WithDefaultConstructor_ShouldUseNonePreference()
+ {
+ // Arrange
+ var ipEnricher = new ClientIpEnricher(_contextAccessor, IpVersionPreference.None);
+ _contextAccessor.HttpContext!.Connection.RemoteIpAddress = IPAddress.Parse("192.168.1.1");
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information(@"Has an IP property");
+
+ // Assert - Should log the IP since default is None preference
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey("ClientIp"));
+ Assert.Equal("192.168.1.1", evt.Properties["ClientIp"].LiteralValue());
+ }
+
+ [Fact]
+ public void ClientIpEnricher_WithPreferenceConstructor_ShouldUseSpecifiedPreference()
+ {
+ // Arrange
+ var ipEnricher = new ClientIpEnricher(_contextAccessor, IpVersionPreference.Ipv4Only);
+ _contextAccessor.HttpContext!.Connection.RemoteIpAddress = IPAddress.Parse("::1");
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information(@"Has an IP property");
+
+ // Assert - Should not log IPv6 with IPv4Only preference
+ Assert.NotNull(evt);
+ Assert.False(evt.Properties.ContainsKey("ClientIp"));
+ }
+}
\ No newline at end of file
diff --git a/test/Serilog.Enrichers.ClientInfo.Tests/ManualFeatureValidationTests.cs b/test/Serilog.Enrichers.ClientInfo.Tests/ManualFeatureValidationTests.cs
new file mode 100644
index 0000000..e1b34f8
--- /dev/null
+++ b/test/Serilog.Enrichers.ClientInfo.Tests/ManualFeatureValidationTests.cs
@@ -0,0 +1,112 @@
+using Microsoft.AspNetCore.Http;
+using NSubstitute;
+using Serilog.Events;
+using System.Net;
+using Xunit;
+
+namespace Serilog.Enrichers.ClientInfo.Tests;
+
+public class ManualFeatureValidationTests
+{
+ [Fact]
+ public void DemonstrateIpv4OnlyFeature()
+ {
+ // Arrange
+ var httpContext = new DefaultHttpContext();
+ var contextAccessor = Substitute.For();
+ contextAccessor.HttpContext.Returns(httpContext);
+
+ // Test with IPv4 address
+ contextAccessor.HttpContext!.Connection.RemoteIpAddress = IPAddress.Parse("192.168.1.1");
+ var ipv4OnlyEnricher = new ClientIpEnricher(contextAccessor, IpVersionPreference.Ipv4Only);
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipv4OnlyEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ log.Information(@"Test IPv4 logging");
+
+ // Assert IPv4 address is logged
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey("ClientIp"));
+ Assert.Equal("192.168.1.1", evt.Properties["ClientIp"].LiteralValue());
+
+ // Test with IPv6 address - should not log
+ evt = null;
+ contextAccessor.HttpContext!.Connection.RemoteIpAddress = IPAddress.Parse("::1");
+
+ log.Information(@"Test IPv6 with IPv4 only");
+
+ // Assert IPv6 address is NOT logged
+ Assert.NotNull(evt);
+ Assert.False(evt.Properties.ContainsKey("ClientIp"));
+ }
+
+ [Fact]
+ public void DemonstrateIpv6OnlyFeature()
+ {
+ // Arrange
+ var httpContext = new DefaultHttpContext();
+ var contextAccessor = Substitute.For();
+ contextAccessor.HttpContext.Returns(httpContext);
+
+ // Test with IPv6 address
+ contextAccessor.HttpContext!.Connection.RemoteIpAddress = IPAddress.Parse("::1");
+ var ipv6OnlyEnricher = new ClientIpEnricher(contextAccessor, IpVersionPreference.Ipv6Only);
+
+ LogEvent evt = null;
+ var log = new LoggerConfiguration()
+ .Enrich.With(ipv6OnlyEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ log.Information(@"Test IPv6 logging");
+
+ // Assert IPv6 address is logged
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey("ClientIp"));
+ Assert.Equal("::1", evt.Properties["ClientIp"].LiteralValue());
+
+ // Test with IPv4 address - should not log
+ evt = null;
+ contextAccessor.HttpContext!.Connection.RemoteIpAddress = IPAddress.Parse("192.168.1.1");
+
+ log.Information(@"Test IPv4 with IPv6 only");
+
+ // Assert IPv4 address is NOT logged
+ Assert.NotNull(evt);
+ Assert.False(evt.Properties.ContainsKey("ClientIp"));
+ }
+
+ [Fact]
+ public void DemonstrateExtensionMethodUsage()
+ {
+ // Test that the new extension method works correctly
+ var logger1 = new LoggerConfiguration()
+ .Enrich.WithClientIp() // Default - no preference
+ .WriteTo.Sink(new DelegatingSink(_ => { }))
+ .CreateLogger();
+
+ var logger2 = new LoggerConfiguration()
+ .Enrich.WithClientIp(IpVersionPreference.Ipv4Only) // IPv4 only
+ .WriteTo.Sink(new DelegatingSink(_ => { }))
+ .CreateLogger();
+
+ var logger3 = new LoggerConfiguration()
+ .Enrich.WithClientIp(IpVersionPreference.Ipv6Only) // IPv6 only
+ .WriteTo.Sink(new DelegatingSink(_ => { }))
+ .CreateLogger();
+
+ // Act - just make sure no exceptions are thrown
+ var exception1 = Record.Exception(() => logger1.Information("Test"));
+ var exception2 = Record.Exception(() => logger2.Information("Test"));
+ var exception3 = Record.Exception(() => logger3.Information("Test"));
+
+ // Assert
+ Assert.Null(exception1);
+ Assert.Null(exception2);
+ Assert.Null(exception3);
+ }
+}
\ No newline at end of file
diff --git a/test/Serilog.Enrichers.ClientInfo.Tests/Serilog.Enrichers.ClientInfo.Tests.csproj b/test/Serilog.Enrichers.ClientInfo.Tests/Serilog.Enrichers.ClientInfo.Tests.csproj
index dc3f541..c564bf9 100644
--- a/test/Serilog.Enrichers.ClientInfo.Tests/Serilog.Enrichers.ClientInfo.Tests.csproj
+++ b/test/Serilog.Enrichers.ClientInfo.Tests/Serilog.Enrichers.ClientInfo.Tests.csproj
@@ -1,12 +1,12 @@
- net9.0
+ net8.0
false
-
+