The X-Forwarded-For Header

For situations where knowing the originating public IP (or host name) of a request is needed, .NET provides the Connection property on HttpContext, with a RemoteIpAddress value. Under normal circumstances this is set directly from the incoming request. For example, when running locally under Kestrel this will normally show up as [::1] (IPV6 localhost).

Often, modern web apps aren’t serving requests directly but through proxies that can provide security, scalability, or other benefits, like an Azure Front Door CDN. Normally these forwarded requests will get an X-Forwarded-For (XFF) header added to the request representing the caller to that proxy. If multiple proxies exist between originating client and server, successive values are appended with comma separators. It is important to note that because these are simple header values they can contain anything when first received by your network from the untrusted external caller. For this reason, values should only be read from right to left, with the first value external to your own network infrastructure being treated as the real public IP of the initiator of the call to your network. Anything further to the left should be assumed to be untrusted, as a malicious caller can easily add fake values.

ForwardedHeadersMiddleware

To handle this common use case, ASP.NET Core includes the ForwardedHeadersMiddleware to automatically discard these proxies’ IPs and turn the RemoteIpAddress back into a representation of the actual client IP. The middleware works by reading the XFF header and discarding internal or known addresses until an external IP address is found. As part of this process, addresses are removed from XFF and the initial RemoteIpAddress is added to an X-Original-For header. The intent is that the request ultimately received by the application code should present a Connection property that looks the same as if it hadn’t been routed through those internal proxies.

To get this behavior correct, appropriate config values need to be provided to the middleware, either passed directly to the middleware:

app.UseForwardedHeaders(
    new ForwardedHeadersOptions
    {
        ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
    }
);

or by configuring the options created by DI:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});

var app = builder.Build();
app.UseForwardedHeaders();

Using this basic configuration along with an API endpoint (/myip) that echos back the remote IP value and any forwarding headers, I can send requests with various XFF header values and see what the API actually received.

app.MapGet("/myip", (HttpContext httpContext) =>
{
    return TypedResults.Ok(new
    {
        RemoteIp = httpContext.Connection.RemoteIpAddress?.ToString(),
        Headers = httpContext.Request.Headers.Where(h => h.Key.EndsWith("-For")).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString())
    });
});

Testing With a Proxy

First, to simulate a proxy in my local requests I add an XFF header and now see the middleware updating the IP and header values. Here my actual request is coming from localhost ([::1]) which is the original RemoteIpAddress but ends up in the X-Original-For header (the port will vary depending on the client used to make the request).

GET {{host}}/myip
X-Forwarded-For: 3.3.3.3
{
  "remoteIp": "3.3.3.3",
  "headers": {
    "X-Original-For": "[::1]:63206"
  }
}

So that works for a simple local proxy, but what about more complicated infrastructure? One important part that’s hidden in the config declared above is some default values related to how many forwarded values can be processed and which networks are treated as internal when iterating to find the first external value. Here are the relevant property declarations in ForwardedHeaderOptions:

public int? ForwardLimit { get; set; } = 1;

public IList<IPAddress> KnownProxies { get; } = new List<IPAddress>() { IPAddress.IPv6Loopback };

public IList<IPNetwork> KnownNetworks { get; } = new List<IPNetwork>() { new IPNetwork(IPAddress.Loopback, 8) };

This is affecting our previous request in a few ways. First, our simulated proxy is only a single step, so the ForwardLimit of 1 is sufficient. Because this test took place on localhost, the defaults for the Known properties are also exactly what was needed. Because the request sequence is 3.3.3.3->[::1](client)->[::1](app host), the address that’s showing up as a proxy is that middle one, which fits into the pre-configured Loopback IP ranges.

If I were to do this same test across a network I wouldn’t get the same results, because the IP addresses would not fall into the default configured range. Similarly, if I add in additional forwarding steps to my request, those will be ignored by the middleware. Using the same configuration, a request with an additional XFF address doesn’t get me back to the originating IP that I want.

GET {{host}}/myip
X-Forwarded-For: 3.3.3.3,111.0.0.0
{
  "remoteIp": "111.0.0.0",
  "headers": {
    "X-Forwarded-For": "3.3.3.3",
    "X-Original-For": "[::1]:61970"
  }
}

I still got the same localhost conversion, but it stopped there, and treated the second, unknown, 111 address as external, with the additional 3.3.3.3 address kept as a header but not assumed to be valid. Changing either of the individual configuration values will get the same result, so my new configuration needs to change both the limit and known networks. Here I’ll add a few proxies and set the limit to null to allow any number of intermediate hops, as long as they are in the known list.

app.UseForwardedHeaders(
    new ForwardedHeadersOptions
    {
        ForwardLimit = null,
        KnownProxies = { IPAddress.Parse("111.0.0.0"), IPAddress.Parse("222.0.0.0") },
        ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
    }
);

Now running the request again with an additional XFF value, the 111 and 222 proxies are being discarded, and the 3.3.3.3 value that I want to represent the real client is again shown as the RemoteIpAddress. Note that because I simply added KnownProxies values without removing the defaults, the Loopback config is still present and handling that localhost client address as before.

GET {{host}}/myip
X-Forwarded-For: 3.3.3.3,111.0.0.0,222.0.0.0
{
  "remoteIp": "3.3.3.3",
  "headers": {
    "X-Original-For": "[::1]:62067"
  }
}

Configuring For Azure Front Door

For a more realistic scenario, specific IP addresses may not always be known, but it’s likely that ranges can be found which can plug into the KnownNetworks config value. Azure IP addresses are published as a downloadable file at https://www.microsoft.com/en-us/download/details.aspx?id=56519 or from the Azure CLI or Azure REST API. For Azure Front Door the relevant inbound IP addresses are listed under “AzureFrontDoor.Backend”. It’s a long list but could be put into configuration to be loaded into the options. The provided CIDR notation needs to be split out to create IPNetwork objects so I’ll use a helper method to do that.

private static IPNetwork GetIpNetwork(string cidr)
{
    var parts = cidr.Split('/');
    var prefix = IPAddress.Parse(parts.First());
    if (!int.TryParse(parts.ElementAtOrDefault(1), out int prefixLength))
    {
        prefixLength = prefix.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 128 : 32;
    }

    return new IPNetwork(prefix, prefixLength);
}

And then the listed values can be added (a subset of the current list shown here for brevity):

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    foreach (IPNetwork item in new[]
            {
                "52.228.80.120/29",
                "68.221.93.128/29",
                "102.133.56.88/29",
                "102.133.216.88/29",
                "147.243.0.0/16",
                "2603:1050:403::5c0/123",
                "2a01:111:20a::/48",
                "2a01:111:2050::/44",
            }.Select(GetIpNetwork))
    {
        options.KnownNetworks.Add(item);
    }
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});

var app = builder.Build();
app.UseForwardedHeaders();

This configuration will remove a single Front Door proxy address for something like an App Service + Front Door setup, but more complex app hosting, like a Kubernetes cluster, may have other proxy addresses that need to be added (i.e. 10.x.x.x) and the ForwardLimit accordingly increased or set to null.

Code Versions

Example code is using .NET 7.0 and .http file support in Visual Studio 2022 v17.6.