About Docker, Reverse Proxies, and Client IPs

April 27, 2021 4 minutes

A common task in many applications is to log the remote client IP of the current request.

In ASP.NET Core, if you have access to the HttpContext object, you can obtain it like this:

IPAddress clientIp = context.Connection.RemoteIpAddress

Of course, things are never that simple, are they?

Reverse Proxies

Kestrel is fast, but it’s also lightweight by design. You’re expected to run your ASP.NET Core application behind another, more featureful, web server.

A common setup for single-host deployments is to run Nginx as a SSL terminating reverse-proxy as follows:

![Reverse proxy diagram](reverse-proxy.svg)
Reverse proxy, from Wikipedia.org

In this scenario, our application isn’t exposed to the Internet; it receives requests from Nginx, so executing our previous code example would just return our local IP address (usually 127.0.0.1 or ::1).

In order to get the remote client IP address, we need to do two things:

  1. Configure our server blocks in Nginx so it forwards information about the remote connection to our application.
  2. Configure the application so it extracts that information and uses it to set HttpContext.Connection.RemoteIpAddress to the correct value.

The former is achieved by using the proxy_set_header to add some headers to the request, usually X-Forwarded-For and X-Forwarded-Proto:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

Some people also like to add the X-Real-IP header, but we won’t because of reasons we’ll explain later.

To configure the application to actually make use of X-Forwarded-For and X-Forwarded-Proto, we’ll have to import the Microsoft.AspNetCore.HttpOverrides namespace, and modify Startup as follows:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<ForwardedHeadersOptions>(options =>
        {
            options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
                                       ForwardedHeaders.XForwardedProto;
        });
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseForwardedHeaders();

        // ... everything else
    }
}

This will work fine for non-containerized deployments.

A small detour: X-Real-IP

Simply put, adding the X-Real-IP header is a bit redundant for our case.

  1. When it arrives at Kestrel X-Forwarded-For will contain a list of IP addresses; the first one corresponds to the remote client’s, and the following to any proxy that the request passed by.
  2. ForwardedHeadersMiddleware ignores X-Real-IP (source code).

The examples I saw that included it basically did as a hacky workaround to the issue we’ll explore next.

Docker, and It Doesn’t Work

If you’re running your ASP.NET Core application in a Docker container with bridge networking, then you’ll probably had a different experience:

  • The X-Forwarded-* headers were ignored.
  • No warning or error was logged.
  • The RemoteIpAddress was something like ::ffff:172.16.0.1

That happened because ForwardedHeadersMiddleware has a default configuration in place to help prevent header injection attacks.

The default assumptions are:

  • There is only one proxy between the app and the source of the requests.
  • Only loopback addresses are configured for known proxies and known networks.
  • The forwarded headers are named X-Forwarded-For and X-Forwarded-Proto.

The second assumption is relevant for us, as any “forwarded” header coming from anywhere that’s not a known proxy or from a known network will be ignored completely.

ForwardedHeadersMiddleware.cs:366:

private bool CheckKnownAddress(IPAddress address)
{
    if (address.IsIPv4MappedToIPv6)
    {
        var ipv4Address = address.MapToIPv4();
        if (CheckKnownAddress(ipv4Address))
        {
            return true;
        }
    }
    if (_options.KnownProxies.Contains(address))
    {
        return true;
    }
    foreach (var network in _options.KnownNetworks)
    {
        if (network.Contains(address))
        {
            return true;
        }
    }
    return false;
}
(Actually, that's just the check definition, not where the check is done... but the [actual method](https://github.com/dotnet/aspnetcore/blob/main/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs#L163) is 200 lines long and fugly enough for me to ignore it for now.)

Inside the container, requests coming from the host (e.g. from Nginx) will appear as if they were coming from the Docker bridge gateway itself.

Therefore:

  • If you’re using a custom bridge network with a custom CIDR range, add it to KnownNetworks. Or add the Docker bridge gateway IP to the KnownProxies collection.
  • If you’re using the default bridge, without manually defined ranges, you can always just… set the entirety of the 172.16.0.0/12 range as a “known network”.

An example of the second scenario:

// Manually add Docker address to trusted addresses
// otherwise, the forwarded headers are ignored
services.Configure<ForwardedHeadersOptions>(options =>
{
    options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("::ffff:172.16.0.0"), 12));
    // options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
                               ForwardedHeaders.XForwardedProto;
});

NOTE: the default network uses IPv6. If you use IPv4 in your custom network then you’ll have to use IPv4 address for the range.

More scenarios: containerized Nginx and reverse proxies over WAN

It’s a matter of preference, but I don’t containerize Nginx or DBMS. I’ll probably do some testing this week and I’ll update this post.

Regarding non-local/non-LAN reverse proxies, that’s a bit unusual but it’s also planned for a future post.

Summary

Using ForwardedHeadersMiddleware is a simple way to handle commonly forwarded headers like X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host.

By using this middleware, it’s possible for us to just simply access the remote client’s IP address in the same way regardless of whether we’re behind a reverse proxy or not.

By adding the appropriate IP ranges to KnownNetworks or IP addresses to KnownProxies we can also use the middleware for containerized applications, saving us from the effort of having to extract header values by ourselves.