Adding ASP.NET Rate Limiting Policies to individual Pages, Folders and Areas through Page Conventions

Microsoft introduced rate limiting middleware into ASP.NET which allows you to setup different policies with rate limiting strategies and settings.

The most useful in my opinion being the IP based sliding window, that only allows a certain number of requests by IP in a specific period. In this example we are setting a limit of 10 requests by minute.

builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("name-of-policy", httpContext =>
        RateLimitPartition.GetSlidingWindowLimiter(
            partitionKey:
               $"policy-{httpContext.Connection.RemoteIpAddress}",
            factory: _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = 10,
                Window = TimeSpan.FromMinutes(1)
            }));

});

Using those policies for Minimal API endpoints is trivial, using the RequireRateLimiting extension.

app.MapGet("/", async () =>
{
    ...
}).RequireRateLimiting("name-of-policy");

Similary if you want to apply a policy to all MVC controllers and Razor pages you can use a similar extension:

app.MapRazorPages().RequireRateLimiting("name-of-policy");
app.MapDefaultControllerRoute().RequireRateLimiting("name-of-policy");

If you instead want to add the policy in the Controller or Razor Page directly you must use the [EnableRateLimiting("name-of-policy")] attribute. For Razor Pages you can only apply this to the PageModel itself (not the individual GET/POST or page handler methods).

For Authorization on the other hand ASP.NET allows a centralized configuration via authorization conventions in the AddRazorPages options configuration:

services.AddRazorPages(options =>
{
    options.Conventions.AuthorizePage("/Contact");
    options.Conventions.AuthorizeFolder("/Private");
    options.Conventions.AllowAnonymousToPage("/Private/PublicPage");
    options.Conventions.AllowAnonymousToFolder("/Private/PublicPages");
});

Microsoft has not provided the same functionality for rate limiting policies. Ideally you would want these:

builder.Services.AddRazorPages(options =>
{
   options.Conventions.RateLimitPage("/Page", "policy");
   options.Conventions.RateLimitFolder("/Folder", "policy");
   options.Conventions.RateLimitAreaPage("Area", "/Page", "policy");
   options.Conventions.RateLimitAreaFolder("Area", "/Folder", "plcy");
});

Thankfully creating the extension methods for these is fairly simple (as long as you are using endpoint routing) which I have done as part of the Minimal Identity UI project. Here you can find the full source code for the extension metho class or see below for the contents:

using Microsoft.AspNetCore.Mvc.ApplicationModels;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Humanizer.Localisation;

/// <summary>
/// Allows setting rate limiting policy on pages, folders and areas similar
/// to the Authorize* extension methods.
///
/// Note this will only work if you endpoint routing is enabled.
///
/// Order of pipeline execution is important:
/// Routing > Rate Limiter > MapRazorPages
///
/// Example pipeline:
/// app.UseRouting();
/// app.UseForwardedHeaders(); // Only necessary if behind a reverse proxy
/// app.UseRateLimiter();
/// app.UseAuthorization();
/// app.MapRazorPages();
///
/// Example usage:
/// builder.Services.AddRazorPages(options =>
/// {
///    options.Conventions.RateLimitPage("/Page", "policy");
///    options.Conventions.RateLimitFolder("/Folder", "policy");
///    options.Conventions.RateLimitAreaPage("Area", "/Page", "policy");
///    options.Conventions.RateLimitAreaFolder("Area", "/Folder", "policy");
/// });
/// </summary>
public static class RateLimitingPageConventionCollectionExtensions
{
	public static PageConventionCollection RateLimitPage(
		this PageConventionCollection conventions,
		string pageName,
		string policy)
	{
		ArgumentNullException.ThrowIfNull(conventions);

		if (string.IsNullOrEmpty(pageName))
		{
			throw new ArgumentException(nameof(pageName));
		}

		conventions.AddPageApplicationModelConvention(pageName, model =>
		{
			model.EndpointMetadata.Add(new EnableRateLimitingAttribute(policy));
		});
		return conventions;
	}

	public static PageConventionCollection RateLimitAreaPage(
		this PageConventionCollection conventions,
		string areaName,
		string pageName,
		string policy)
	{
		ArgumentNullException.ThrowIfNull(conventions);

		if (string.IsNullOrEmpty(areaName))
		{
			throw new ArgumentException(nameof(areaName));
		}

		if (string.IsNullOrEmpty(pageName))
		{
			throw new ArgumentException(nameof(pageName));
		}

		conventions.AddAreaPageApplicationModelConvention(areaName, pageName, model =>
		{
			model.EndpointMetadata.Add(new EnableRateLimitingAttribute(policy));
		});
		return conventions;
	}

	public static PageConventionCollection RateLimitFolder(
		this PageConventionCollection conventions,
		string pageName,
		string policy)
	{
		ArgumentNullException.ThrowIfNull(conventions);

		if (string.IsNullOrEmpty(pageName))
		{
			throw new ArgumentException(nameof(pageName));
		}

		conventions.AddFolderApplicationModelConvention(pageName, model =>
		{
			model.EndpointMetadata.Add(new EnableRateLimitingAttribute(policy));
		});

		return conventions;
	}

	public static PageConventionCollection RateLimitAreaFolder(
		this PageConventionCollection conventions,
		string areaName,
		string folderPath,
		string policy)
	{
		ArgumentNullException.ThrowIfNull(conventions);

		if (string.IsNullOrEmpty(areaName))
		{
			throw new ArgumentException(nameof(areaName));
		}

		if (string.IsNullOrEmpty(folderPath))
		{
			throw new ArgumentException(nameof(folderPath));
		}

		conventions.AddAreaFolderApplicationModelConvention(areaName, folderPath, model =>
		{
			model.EndpointMetadata.Add(new EnableRateLimitingAttribute(policy));
		});
		return conventions;
	}
}