From ec7b0af0b141123e774d722fc80ce42dacb93fdf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:43:05 +0000 Subject: [PATCH 1/3] Initial plan From ded56b0fb570bfb43ee4b4f58f85593616c9584f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:48:21 +0000 Subject: [PATCH 2/3] Initial commit - fix .NET target frameworks for .NET 8.0 compatibility Co-authored-by: mo-esmp <1659032+mo-esmp@users.noreply.github.com> --- .../Serilog.Enrichers.ClientInfo.csproj | 2 +- .../Serilog.Enrichers.ClientInfo.Tests.csproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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 - + From 55017bb90bea2aa7bb44a3ccb5f11020bd55d45f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:55:52 +0000 Subject: [PATCH 3/3] Implement IPv4/IPv6 preference feature for ClientIpEnricher Co-authored-by: mo-esmp <1659032+mo-esmp@users.noreply.github.com> --- README.md | 18 ++ .../Enrichers/ClientIpEnricher.cs | 49 +++- .../Enrichers/IpVersionPreference.cs | 32 +++ ...ClientInfoLoggerConfigurationExtensions.cs | 16 ++ ...lientIpEnricherIpVersionPreferenceTests.cs | 210 ++++++++++++++++++ .../ManualFeatureValidationTests.cs | 112 ++++++++++ 6 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 src/Serilog.Enrichers.ClientInfo/Enrichers/IpVersionPreference.cs create mode 100644 test/Serilog.Enrichers.ClientInfo.Tests/ClientIpEnricherIpVersionPreferenceTests.cs create mode 100644 test/Serilog.Enrichers.ClientInfo.Tests/ManualFeatureValidationTests.cs 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/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