skip to content
Jerrie Pelser's Blog

Limiting API callers to specific IP Addresses

/ 6 min read

Background

Cloudpress allow users to export content from Google Docs to various Content Management Systems. Users can use the Cloudpress application to export the content, or they can use our Google Docs add-on.

The Cloudpress Google Docs Add-on calls a Cloudpress API endpoint to export a document. The endpoint is only used by the Google Docs Add-on and should not be accessible to any other callers.

To enforce this, I developed a custom authorization handler that checks the IP address of the caller and ensures that it comes from one of the Google IP addresses. In the remainder of this blog post, I show you how you can implement something like this for your projects.

Obtaining the list of valid IP addresses

Before we can create the authorization handler, we need to get the list of IP addresses that is used by the Google Apps Script runtime. Google publishes this list in a file located at https://www.gstatic.com/ipranges/goog.txt.

If you open this file in your browser, you will see something like the following:

8.8.4.0/24
8.8.8.0/24
8.34.208.0/20
...
2800:3f0::/32
2a00:1450::/32
2c0f:fb50::/32

This file uses CIDR (Classless Inter-Domain Routing) notation to specify the ranges of IP addresses used by the Apps Script runtime. I will not go into a lot of detail on how CIDR works, but the short of it is that 8.8.4.0/24 specifies that it includes all the IP addresses in the range from 8.8.4.0 to 8.8.4.255.

On the .NET side, you can use the IPNetwork struct to parse CIDR and subsequently check whether a given IP address is part of that network.

// See fiddle at https://dotnetfiddle.net/Ooqq9R
// Parse the CIDR string
var ipNetwork = IPNetwork.Parse("8.8.4.0/24");
// The following line prints "True"
Console.WriteLine(ipNetwork.Contains(IPAddress.Parse("8.8.4.20")));
// The following line prints "False"
Console.WriteLine(ipNetwork.Contains(IPAddress.Parse("192.168.0.0")));

Loading the IP address ranges at startup

Since this list can change over time, we want to load it every time at application startup. For this purpose, I will create a hosted service in my ASP.NET Core application.

public class GoogleIpNetworkStorage(HttpClient httpClient, ILogger<GoogleIpNetworkStorage> logger) : IHostedService
{
private readonly List<IPNetwork> _ipNetworks = new List<IPNetwork>();
public bool ContainsIp(IPAddress ipAddress)
{
return _ipNetworks.Any(ipNetwork => ipNetwork.Contains(ipAddress));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
try
{
logger.LogInformation("Loading Google IP ranges");
var text = await httpClient.GetStringAsync("https://www.gstatic.com/ipranges/goog.txt", cancellationToken);
_ipNetworks.AddRange(text.GetLines().Select(line => IPNetwork.Parse(line)));
logger.LogInformation("Google IP ranges loaded successfully");
}
catch (Exception e)
{
logger.LogCritical(e, "An error occurred while attempting to load the Google IP ranges");
throw;
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

Most of the code above is boilerplate and logging. The two highlighted lines in the StartAsync method download the file containing the IP ranges from Google, parses it, and adds the ranges to an internal variable.

I also added a ContainsIp() method that takes an IP address as a parameter and returns a boolean value indicating whether the IP address is part of the Google IP ranges.

The hosted service is registered with the DI container by calling the AddHostedService extension method. Later on, we also want to be able to inject the GoogleIpNetworkStorage instance into our authorization handler. To do that, we register it as a Singleton, where the instance is retrieved from the list of hosted services.

builder.Services.AddHostedService<GoogleIpNetworkStorage>();
builder.Services.AddSingleton(provider => provider.GetServices<IHostedService>().OfType<GoogleIpNetworkStorage>().First());

When our ASP.NET Core application starts up, the StartAsync method from the hosted service will be executed and the list will be loaded from Google.

The Google IP addresses are loaded during startup

Creating a custom authorization handler

For the authorization handler, I create an authorization requirement named RequiresGoogleIpAddressRequirement and an authorization handler named RequiresGoogleIpAddressAuthorizationHandler that will evaluate the requirement.

The authorization handler itself is fairly simple as it retrieves the IP address of the caller from the RemoteIpAddress property and then query the GoogleIpNetworkStorage instance that was injected to determine if the IP address is part of the Google network.

public class RequiresGoogleIpAddressRequirement: IAuthorizationRequirement
{
}
public class RequiresGoogleIpAddressAuthorizationHandler(IHttpContextAccessor httpContextAccessor, GoogleIpNetworkStorage googleIpNetworkStorage)
: AuthorizationHandler<RequiresGoogleIpAddressRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresGoogleIpAddressRequirement requirement)
{
var remoteIpAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
if (remoteIpAddress != null && googleIpNetworkStorage.ContainsIp(remoteIpAddress))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}

We also need to add a custom authorization policy with the RequiresGoogleIpAddressRequirement requirment.

builder.Services.AddAuthentication("Bearer").AddJwtBearer();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequiresGoogleIpAddress", policy => policy.Requirements.Add(new RequiresGoogleIpAddressRequirement()));
});

And ensure that we specify that requirement for the endpoints we want to protect.

app.MapGet("/weatherforecast", () =>
{
// .. code omitted for brevity
})
.RequireAuthorization("RequiresGoogleIpAddress");

Testing the authorization handler locally

If we run the application and make a call to our weather endpoint, we’ll get a 401 (Unauthorized) response.

The authorization handler is rejecting calls from local machine

This is the correct behaviour, as the remote IP Address is our local IP (127.0.0.1) which is not one of the Google IP addresses. We need a way to “spoof” one of the Google IP addresses so we can confirm our authorization handler is working as intended.

For this we can configure the ForwardedHeadersOptions to allow the X-Forwarded-For header.

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

And then add the forwarded headers middleware to our pipeline. Make sure to add this as the first middleware in the pipeline, so the remote IP address will be set according to the X-Forwarded-For header.

if (app.Environment.IsDevelopment())
{
app.UseForwardedHeaders();
}

Once gain, we can test this and pass one of the valid Google IPs in the X-Forwarded-For header. This time, the authorization handler succeeds, and we get a 200 (OK) response.

Authorization is successful when using the X-Forwarded-For header

Conclusion

In this blog post, I showed how you can create an authorization handler for ASP.NET Core that restricts the caller of API endpoints to specific IP addresses. You can find the code for this blog post at https://github.com/jerriepelser-blog/limit-api-callers-to-specific-ip-addresses