Skip to content

Add IPv4/IPv6 preference support to ClientIpEnricher #55

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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`
Expand Down
49 changes: 46 additions & 3 deletions src/Serilog.Enrichers.ClientInfo/Enrichers/ClientIpEnricher.cs
Original file line number Diff line number Diff line change
@@ -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")]
Expand All @@ -14,17 +16,27 @@ public class ClientIpEnricher : ILogEventEnricher
private const string IpAddressItemKey = "Serilog_ClientIp";

private readonly IHttpContextAccessor _contextAccessor;
private readonly IpVersionPreference _ipVersionPreference;

/// <summary>
/// Initializes a new instance of the <see cref="ClientIpEnricher"/> class.
/// </summary>
public ClientIpEnricher() : this(new HttpContextAccessor())
public ClientIpEnricher() : this(new HttpContextAccessor(), IpVersionPreference.None)
{
}

internal ClientIpEnricher(IHttpContextAccessor contextAccessor)
/// <summary>
/// Initializes a new instance of the <see cref="ClientIpEnricher"/> class.
/// </summary>
/// <param name="ipVersionPreference">The IP version preference for filtering IP addresses.</param>
public ClientIpEnricher(IpVersionPreference ipVersionPreference) : this(new HttpContextAccessor(), ipVersionPreference)
{
}

internal ClientIpEnricher(IHttpContextAccessor contextAccessor, IpVersionPreference ipVersionPreference = IpVersionPreference.None)
{
_contextAccessor = contextAccessor;
_ipVersionPreference = ipVersionPreference;
}

/// <inheritdoc/>
Expand All @@ -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)
{
Expand All @@ -53,4 +78,22 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
httpContext.Items.Add(IpAddressItemKey, ipAddressProperty);
logEvent.AddPropertyIfAbsent(ipAddressProperty);
}

/// <summary>
/// Applies IP version filtering based on the configured preference.
/// </summary>
/// <param name="ipAddress">The IP address to filter.</param>
/// <returns>The filtered IP address, or null if it should be excluded.</returns>
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
};
}
}
32 changes: 32 additions & 0 deletions src/Serilog.Enrichers.ClientInfo/Enrichers/IpVersionPreference.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Serilog.Enrichers;

/// <summary>
/// Specifies the IP version preference for client IP enrichment.
/// </summary>
public enum IpVersionPreference
{
/// <summary>
/// No preference - use whatever IP version is available (default behavior).
/// </summary>
None = 0,

/// <summary>
/// Prefer IPv4 addresses when available, fallback to IPv6 if IPv4 is not available.
/// </summary>
PreferIpv4 = 1,

/// <summary>
/// Prefer IPv6 addresses when available, fallback to IPv4 if IPv6 is not available.
/// </summary>
PreferIpv6 = 2,

/// <summary>
/// Only log IPv4 addresses, ignore IPv6 addresses.
/// </summary>
Ipv4Only = 3,

/// <summary>
/// Only log IPv6 addresses, ignore IPv4 addresses.
/// </summary>
Ipv6Only = 4
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ public static LoggerConfiguration WithClientIp(
return enrichmentConfiguration.With<ClientIpEnricher>();
}

/// <summary>
/// Registers the client IP enricher to enrich logs with <see cref="Microsoft.AspNetCore.Http.ConnectionInfo.RemoteIpAddress"/> value.
/// </summary>
/// <param name="enrichmentConfiguration">The enrichment configuration.</param>
/// <param name="ipVersionPreference">The IP version preference for filtering IP addresses.</param>
/// <exception cref="ArgumentNullException">enrichmentConfiguration</exception>
/// <returns>The logger configuration so that multiple calls can be chained.</returns>
public static LoggerConfiguration WithClientIp(
this LoggerEnrichmentConfiguration enrichmentConfiguration,
IpVersionPreference ipVersionPreference)
{
ArgumentNullException.ThrowIfNull(enrichmentConfiguration, nameof(enrichmentConfiguration));

return enrichmentConfiguration.With(new ClientIpEnricher(ipVersionPreference));
}

/// <summary>
/// Registers the correlation id enricher to enrich logs with correlation id with
/// 'x-correlation-id' header information.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>net8.0</TargetFrameworks>
<AssemblyName>Serilog.Enrichers.ClientInfo</AssemblyName>
<RootNamespace>Serilog</RootNamespace>
<LangVersion>latest</LangVersion>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IHttpContextAccessor>();
_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"));
}
}
Loading