Add IP whitelist action filter to .NET 5 MVC

Sometimes you want to limit access to a specific controller and / or action within your ASP.NET 5 MVC application to a specific IP address, e.g. to the outgoing static IP address of your company network.

One handy way to achieve this is to create your own custom filter. There are various types of filters which kick in the request pipeline in a specific order. For our purpose we make use of an action filter, which runs code immediately before and after an action method is called.

Implementation

The implementation consists of four parts:

  1. The custom action filter
  2. A configuration in your appsettings.json
  3. Adding header override to receive requesting client IP address
  4. Applying the attribute on a controller (or whatever makes sense for you)

Let's start with a look at the custom action filter (breakdown follows):

using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Your.Own.Project.Core.Attributes
{
    public class ClientIpCheckActionFilter : ActionFilterAttribute
    {
        private readonly IWebHostEnvironment _environment;
        private readonly ILogger<ClientIpCheckActionFilter> _logger;
        private readonly string _whitelistConfigKey;
        
        private string _safelist;
        

        public ClientIpCheckActionFilter(
            string whitelistConfigKey,
            IWebHostEnvironment env,
            ILogger<ClientIpCheckActionFilter> logger)
        {
            _whitelistConfigKey = whitelistConfigKey;
            _logger = logger;
            _environment = env;
        }

        public override void OnActionExecuting(ActionExecutingContext context)
        {
            if(_environment.IsDevelopment())
            {
                base.OnActionExecuting(context);
                return;
            }

            var config = context.HttpContext.RequestServices.GetService<IConfiguration>();
            if (!string.IsNullOrWhiteSpace(_whitelistConfigKey))
            {
                _safelist = config[_whitelistConfigKey];
            }

            if (string.IsNullOrWhiteSpace(_safelist))
            {
                _logger.LogWarning("Client IP check attribute applied, but no IP whitelist configured. All requests will be blocked.");
                context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
                return;
            }

            var remoteIp = context.HttpContext.Connection.RemoteIpAddress;
            var ips = _safelist.Split(';');
            var badIp = true;

            if (remoteIp.IsIPv4MappedToIPv6)
            {
                remoteIp = remoteIp.MapToIPv4();
            }

            foreach (var address in ips)
            {
                var testIp = IPAddress.Parse(address);

                if (testIp.Equals(remoteIp))
                {
                    badIp = false;
                    break;
                }
            }

            if (badIp)
            {
                _logger.LogInformation("Client IP not part of the whitelist configured. Request blocked.");
                context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
                return;
            }

            base.OnActionExecuting(context);
        }
    }
}

So, what does it do?

Line 13: Make a subclass of ActionFilterAttribute to be able to return an error if client IP address is invalid.

Line 15 - 17: These will hold the references to your current web hosting environment and your preferred Logger (always good to have one ;) ) - passed in by Dependency Injection -, as well as a parameter to provide the path to the configuration setting for the IP whitelist.

Line 19: The list of valid IP addresses, for which access shall be granted.

Line 22 - 30: Constructor wiring up the DIs as well as the parameter provided.

Line 32: As we want to validate access before the actual action is executed, we override the method OnActionExecuting.

Line 34 - 38: When debugging on your local machine, no remote IP address will be available and therefore the check will always fail. As you may want to debug the part of your application where the filter is applied nevertheless - I guess -, we will just skip the whitelist check. Feel free to create a more sophisticated logic if you like :)

Line 40: Get access to the applications configuration by DI.

Line 41 - 44: If a configuration key (path) has been provided, try to read the value from the configuration and store the whitelist to the _safelist.

Line 46 - 51: If no IPs have been configured, we will block the request (actually each request). Why? Because we want to avoid to provide the feeling of false security. If the action filter attribute has been added, I expect requests to be blocked corresponding to the IP whitelist I configured. But what if I simply forgot (or cleared by accident) the IP whitelist from the config. In case we would just let the request pass this test, you may not even recognize directly that requests coming from illegitimate IP addresses are not blocked. On the other hand, if all requests are blocked, safety is not at risk and chances are high you will recognize the issue right away.

Line 53: Get the client's remote IP address.

Line 54: Split the IPs configured in the whitelist by ';'.

Line 55: Per default, each client IP is a bad IP :)

Line 57 - 60: If the client IP address is a v4 address mapped to v6, we map it back to v4. You may want to add more logic to actually handle v6 addresses, too, but it's not part of this little demo.

Line 62 - 71: Loop through the whitelist IPs and compare each to the client's IP address. If one of the whitelist list entries matches - cool - it's no longer a bad IP. And yes, we could do the same using a Linq statement.

Line 73 - 78: If the client's IP address was not in the whitelist and is therefore still a bad IP, we log the issue and return a 403. Sry man, your IP just sucks...

Line 80: Otherwise we will just continue as if nothing happened...


Let's have a quick look at the configuration in your appsettings.json:

{
  // some config here...,
  "IpRestriction": {
  	"MyAreaWhitelist": "77.109.191.60;77.109.191.78"
  }
  //, some more config here
}

Nothing fancy, right? You may want to configure more then one IP whitelist, e.g. for different areas within your application. So it may make sense to put all these whitelists within a specific section - like "IpRestriction", just a bit more creative. For each whitelist, add the IP addresses separated with a semicolon.


Now, the third step is the one I stumbled a bit when testing with my mobile through ngrok, as my remote IP address was always null. It turned out that this is a question of where the HTTP request is actually terminated - and then forwarded to the application. It is especially important in scenarios where your application lives behind a reverse proxy.

StackOverflow to the rescue! This issue brought me on the right direction: c# - How do I get client IP address in ASP.NET CORE? - Stack Overflow

Basically, we have to use the Forwarded Header Middleware from the Microsoft.AspNetCore.HttpOverrides package. The middleware updates the Request.Scheme, using the X-Forwarded-Proto header, so that redirect URIs and other security policies work correctly. Forwarded Headers Middleware should run before other middleware. This ordering ensures that the middleware relying on forwarded headers information can consume the header values for processing. See Host ASP.NET Core on Linux with Nginx | Microsoft Docs for more details.

So we add the nuget package to our .csproj:

<PackageReference Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />

and configure the middleware in Startup.cs:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...
    app.UseForwardedHeaders(new ForwardedHeadersOptions
    {
        ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
    });
// ...
}

Time to make use of out fancy new action filter attribute.

[TypeFilter(typeof(ClientIpCheckActionFilter),
        Arguments = new object[] {"IpRestriction:MyAreaWhitelist"})]
public class MyFancyController : Controller
{
	//...
}

We make use of the TypeFilter attribute to receive missing constructor arguments from Dependency Injection (for the IWebHostEnvironment and the ILogger). The missing parameter for the configuration key / path to be used is provided as argument.

That's it: All requests going to a method in our MyFancyController will first run though our ClientIPCheck action filter and, if we don't like the client's IP, will be rejected as unauthorized.

As always, the code is a demo and there are plenty of parts to be improved - IP ranges, blacklist and IPv6 to name just a few. However, I hope this gives an idea of how IP based access control could be implemented. You may want to check out these links for additional ideas and background, too:

What do you think? Just leave a comment below.

Show Comments