About Docker, Reverse Proxies, and Client IPs
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:
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:
- Configure our server blocks in Nginx so it forwards information about the remote connection to our application.
- 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.
- 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.
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 theKnownProxies
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”.
- I would really advise against using the default bridge network though. Even the docs argue against it, for several reasons.
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.