Acquiring client originating IP address (with Akamai and CloudFlare support) in ASP.NET Core

Sometimes there is a need for a web application to acquire client originating IP address (location dependent content, audit requirements etc.). ASP.NET Core provides HttpContext.Connection.RemoteIpAddress property which provides originating IP address for the connection. In todays web the origin of connection seen by the web server is rarely the client, more likely the last proxy on the path. So, in order to attempt acquiring the client originating IP address developer needs to work a little bit more harder than using a property.

The most common way of preserving the client IP address is the X-Forwarded-For header (it's even more popular than standardized Forwarded header). It should contain a comma separated list of addresses which represent the request path through the network. The first entry on that list should be the client address. The X-Forwarded-For header in ASP.NET Core is supported out of the box by Forwarded Headers Middleware, it just needs to be added to the pipeline.

Unfortunately, the X-Forwarded-For header is often not implemented correctly by proxies (for example they might override the value instead of appending to it). In this context (and fact that Forwarded headers is not picking up as quickly as it should) the global reverse proxy provides like Akamai or CloudFlare have done what typically happens in software for such cases - they have introduced they own headers. Akamai is using True-Client-IP (it's not unique to Akamai to be precise, there are some others who are using it by Akamai is the biggest) and CloudFlare is using CF_CONNECTING_IP.

So, how all of those can be handled within an application if needed? Luckily the Forwarded Headers Middleware can be registered multiple times.

Stacking Forwarded Headers Middleware

The Forwarded Headers Middleware supports multiple headers with different responsibilities. The options allow for providing different names for those headers and choosing which one should be processed. Assuming that part of the stack will be Forwarded Headers Middleware configured to handle its default headers, two more needs to be added. For each one of them an extension method can be created to encapsulate the configuration.

For Akamai the ForwardedForHeaderName property should be set to True-Client-IP and processing limited to XForwardedFor.

public static class ForwardedHeadersExtensions
{
    public static IApplicationBuilder UseAkamaiTrueClientIp(this IApplicationBuilder app)
    {
        if (app == null)
        {
            throw new ArgumentNullException(nameof(app));
        }

        return app.UseForwardedHeaders(new ForwardedHeadersOptions
        {
            ForwardedForHeaderName = "True-Client-IP",
            ForwardedHeaders = ForwardedHeaders.XForwardedFor
        });
    }
}

The only difference in case of CloudFlare configuration is ForwardedForHeaderName property value.

public static class ForwardedHeadersExtensions
{
    ...

    public static IApplicationBuilder UseCloudFlareConnectingIp(this IApplicationBuilder app)
    {
        if (app == null)
        {
            throw new ArgumentNullException(nameof(app));
        }

        return app.UseForwardedHeaders(new ForwardedHeadersOptions
        {
            ForwardedForHeaderName = "CF_CONNECTING_IP",
            ForwardedHeaders = ForwardedHeaders.XForwardedFor
        });
    }
}

This allows to easily stack the middleware.

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseForwardedHeaders(new ForwardedHeadersOptions
    {
        ForwardedHeaders = ForwardedHeaders.All
    });
    app.UseAkamaiTrueClientIp();
    app.UseCloudFlareConnectingIp();

    ...
}

This works, but you might want something more. One of the issues with forwarded headers is trust - they can be easily spoofed. In case of global providers there are options to protect against that. For simplicity I will focus on CloudFlare from this point.

Preventing CloudFlare Connecting IP spoofing

CloudFlare makes its IP ranges available here. This list can be used to validate if incoming request is allowed to carry CF_CONNECTING_IP header as CloudFlare should be the last intermediary in front of the server. To perform that validation one can start with wrapping the ForwardedHeadersMiddleware.

public class CloudFlareConnectingIpMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ForwardedHeadersMiddleware _forwardedHeadersMiddleware;

    public CloudFlareConnectingIpMiddleware(RequestDelegate next, ILoggerFactory loggerFactory)
    {
        _next = next ?? throw new ArgumentNullException(nameof(next));

        _forwardedHeadersMiddleware = new ForwardedHeadersMiddleware(next, loggerFactory,
            Options.Create(new ForwardedHeadersOptions
        {
            ForwardedForHeaderName = "CF_CONNECTING_IP",
            ForwardedHeaders = ForwardedHeaders.XForwardedFor
        }));
    }

    public Task Invoke(HttpContext context)
    {
        return _forwardedHeadersMiddleware.Invoke(context);
    }
}

To validate the request originating IP address the list provided by CloudFlare must be parsed to some usable form. There are existing parsers available, for example IPAddressRange.

public class CloudFlareConnectingIpMiddleware
{
    private static readonly IPAddressRange[] _cloudFlareIpAddressRanges = new IPAddressRange[]
    {
        IPAddressRange.Parse("103.21.244.0/22"),
        ...
        IPAddressRange.Parse("2a06:98c0::/29")
    };

    ...

    private bool IsCloudFlareIp(IPAddress ipadress)
    {
        bool isCloudFlareIp = false;

        for (int i = 0; i < _cloudFlareIpAddressRanges.Length; i++)
        {
            isCloudFlareIp = _cloudFlareIpAddressRanges[i].Contains(ipadress);
            if (isCloudFlareIp)
            {
                break;
            }
        }

        return isCloudFlareIp;
    }
}

With the ability to check if request is incoming from CloudFlare, the ForwardedHeadersMiddleware can be called only when needed.

public class CloudFlareConnectingIpMiddleware
{
    ...

    public Task Invoke(HttpContext context)
    {
        if (context.Request.Headers.ContainsKey("CF_CONNECTING_IP")
            && IsCloudFlareIp(context.Connection.RemoteIpAddress))
        {
            return _forwardedHeadersMiddleware.Invoke(context);
        }

        return _next(context);
    }

    ...
}

Complete code can be found here.

There is one drawback to this approach. What if IP ranges change? This can be handled as well. CloudFlare provides two endpoints which return text lists - one for IPv4 and one for IPv6. Those two endpoints can be used by IHostedService based background task to update the ranges on startup or periodically. I'll leave this as additional exercise.