Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for AspNetCore 7 rate limiting #1967

Merged
merged 10 commits into from
Jan 13, 2023
85 changes: 85 additions & 0 deletions docs/docfx/articles/rate-limiting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Rate Limiting
mburumaxwell marked this conversation as resolved.
Show resolved Hide resolved

## Introduction
The reverse proxy can be used to rate-limit requests before they are proxied to the destination servers. This can reduce load on the destination servers, add a layer of protection, and ensure consistent policies are implemented across your applications.
mburumaxwell marked this conversation as resolved.
Show resolved Hide resolved

> This feature is only available when using .NET 7.0 or later

## Defaults

No rate limiting is performed on requests unless enabled in the route or application configuration. However, the Rate Limiting middleware (`app.UseRateLimiter()`) can apply a default limiter applied to all routes, and this doesn't require any opt-in from the config.

Example:
```
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
builder.Services.AddRateLimiter(options => options.GlobalLimiter = globalLimiter);
```

## Configuration
Rate Limiter policies can be specified per route via [RouteConfig.RateLimiterPolicy](xref:Yarp.ReverseProxy.Configuration.RouteConfig) and can be bound from the `Routes` sections of the config file. As with other route properties, this can be modified and reloaded without restarting the proxy. Policy names are case insensitive.

Example:
```JSON
{
"ReverseProxy": {
"Routes": {
"route1" : {
"ClusterId": "cluster1",
"RateLimiterPolicy": "customPolicy",
"Match": {
"Hosts": [ "localhost" ]
},
}
},
"Clusters": {
"cluster1": {
"Destinations": {
"cluster1/destination1": {
"Address": "https://localhost:10001/"
}
}
}
}
}
}
```

[RateLimiter policies](https://learn.microsoft.com/aspnet/core/performance/rate-limit) are an ASP.NET Core concept that the proxy utilizes. The proxy provides the above configuration to specify a policy per route and the rest is handled by existing ASP.NET Core rate limiting middleware.

RateLimiter policies can be configured in Startup.ConfigureServices as follows:
```
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
public void ConfigureServices(IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("customPolicy", opt =>
{
opt.PermitLimit = 4;
opt.Window = TimeSpan.FromSeconds(12);
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
opt.QueueLimit = 2;
});
});
}
```

In Startup.Configure add the RateLimiter middleware between Routing and Endpoints.

```
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
public void Configure(IApplicationBuilder app)
{
app.UseRouting();

app.UseRateLimiter();

app.UseEndpoints(endpoints =>
{
endpoints.MapReverseProxy();
});
}
```

See the [Rate Limiting](https://learn.microsoft.com/aspnet/core/performance/rate-limit) docs for setting up your preferred kind of rate limiting.

### Disable Rate Limiting

Specifying the value `disable` in a route's `RateLimiterPolicy` parameter means the rate limiter middleware will not apply any policies to this route, even the default policy.
2 changes: 2 additions & 0 deletions docs/docfx/articles/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
href: header-routing.md
- name: Authentication and Authorization
href: authn-authz.md
- name: Rate Limiting
href: rate-limiting.md
- name: Cross-Origin Requests (CORS)
href: cors.md
- name: Session Affinity
Expand Down
8 changes: 8 additions & 0 deletions samples/KubernetesIngress.Sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ metadata:
namespace: default
annotations:
yarp.ingress.kubernetes.io/authorization-policy: authzpolicy
yarp.ingress.kubernetes.io/rate-limiter-policy: ratelimiterpolicy
yarp.ingress.kubernetes.io/transforms: |
- PathRemovePrefix: "/apis"
yarp.ingress.kubernetes.io/route-headers: |
Expand Down Expand Up @@ -73,6 +74,7 @@ The table below lists the available annotations.
|Annotation|Data Type|
|---|---|
|yarp.ingress.kubernetes.io/authorization-policy|string|
|yarp.ingress.kubernetes.io/rate-limiter-policy|string|
|yarp.ingress.kubernetes.io/backend-protocol|string|
|yarp.ingress.kubernetes.io/cors-policy|string|
|yarp.ingress.kubernetes.io/health-check|[ActivateHealthCheckConfig](https://microsoft.github.io/reverse-proxy/api/Yarp.ReverseProxy.Configuration.ActiveHealthCheckConfig.html)|
Expand All @@ -90,6 +92,12 @@ See https://microsoft.github.io/reverse-proxy/articles/authn-authz.html for a li

`yarp.ingress.kubernetes.io/authorization-policy: anonymous`

#### RateLimiter Policy

See https://microsoft.github.io/reverse-proxy/articles/rate-limiting.html for a list of available policies, or how to add your own custom policies.

`yarp.ingress.kubernetes.io/rate-limiter-policy: mypolicy`

#### Backend Protocol

Specifies the protocol of the backend service. Defaults to http.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal sealed class YarpIngressOptions
public bool Https { get; set; }
public List<Dictionary<string, string>> Transforms { get; set; }
public string AuthorizationPolicy { get; set; }
public string RateLimiterPolicy { get; set; }
public SessionAffinityConfig SessionAffinity { get; set; }
public HttpClientConfig HttpClientConfig { get; set; }
public string LoadBalancingPolicy { get; set; }
Expand Down
9 changes: 7 additions & 2 deletions src/Kubernetes.Controller/Converters/YarpParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ private static void HandleIngressRulePath(YarpIngressContext ingressContext, V1S
RouteId = $"{ingressContext.Ingress.Metadata.Name}.{ingressContext.Ingress.Metadata.NamespaceProperty}:{host}{path.Path}",
Transforms = ingressContext.Options.Transforms,
AuthorizationPolicy = ingressContext.Options.AuthorizationPolicy,
RateLimiterPolicy = ingressContext.Options.RateLimiterPolicy,
CorsPolicy = ingressContext.Options.CorsPolicy,
Metadata = ingressContext.Options.RouteMetadata,
Order = ingressContext.Options.RouteOrder,
Expand Down Expand Up @@ -171,16 +172,20 @@ private static YarpIngressOptions HandleAnnotations(YarpIngressContext context,

if (annotations.TryGetValue("yarp.ingress.kubernetes.io/backend-protocol", out var http))
{
options.Https = http.Equals("https", StringComparison.OrdinalIgnoreCase);
options.Https = http.Equals("https", StringComparison.OrdinalIgnoreCase);
}
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/transforms", out var transforms))
{
options.Transforms = YamlDeserializer.Deserialize<List<Dictionary<string,string>>>(transforms);
options.Transforms = YamlDeserializer.Deserialize<List<Dictionary<string, string>>>(transforms);
}
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/authorization-policy", out var authorizationPolicy))
{
options.AuthorizationPolicy = authorizationPolicy;
}
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/rate-limiter-policy", out var rateLimiterPolicy))
{
options.RateLimiterPolicy = rateLimiterPolicy;
}
if (annotations.TryGetValue("yarp.ingress.kubernetes.io/cors-policy", out var corsPolicy))
{
options.CorsPolicy = corsPolicy;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ private static RouteConfig CreateRoute(IConfigurationSection section)
MaxRequestBodySize = section.ReadInt64(nameof(RouteConfig.MaxRequestBodySize)),
ClusterId = section[nameof(RouteConfig.ClusterId)],
AuthorizationPolicy = section[nameof(RouteConfig.AuthorizationPolicy)],
RateLimiterPolicy = section[nameof(RouteConfig.RateLimiterPolicy)],
CorsPolicy = section[nameof(RouteConfig.CorsPolicy)],
Metadata = section.GetSection(nameof(RouteConfig.Metadata)).ReadStringDictionary(),
Transforms = CreateTransforms(section.GetSection(nameof(RouteConfig.Transforms))),
Expand Down
55 changes: 55 additions & 0 deletions src/ReverseProxy/Configuration/ConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ internal sealed class ConfigValidator : IConfigValidator

private readonly ITransformBuilder _transformBuilder;
private readonly IAuthorizationPolicyProvider _authorizationPolicyProvider;
#if NET7_0_OR_GREATER
private readonly IYarpRateLimiterPolicyProvider _rateLimiterPolicyProvider;
#endif
private readonly ICorsPolicyProvider _corsPolicyProvider;
private readonly IDictionary<string, ILoadBalancingPolicy> _loadBalancingPolicies;
private readonly IDictionary<string, IAffinityFailurePolicy> _affinityFailurePolicies;
Expand All @@ -40,6 +43,9 @@ internal sealed class ConfigValidator : IConfigValidator

public ConfigValidator(ITransformBuilder transformBuilder,
IAuthorizationPolicyProvider authorizationPolicyProvider,
#if NET7_0_OR_GREATER
IYarpRateLimiterPolicyProvider rateLimiterPolicyProvider,
#endif
ICorsPolicyProvider corsPolicyProvider,
IEnumerable<ILoadBalancingPolicy> loadBalancingPolicies,
IEnumerable<IAffinityFailurePolicy> affinityFailurePolicies,
Expand All @@ -50,6 +56,9 @@ internal sealed class ConfigValidator : IConfigValidator
{
_transformBuilder = transformBuilder ?? throw new ArgumentNullException(nameof(transformBuilder));
_authorizationPolicyProvider = authorizationPolicyProvider ?? throw new ArgumentNullException(nameof(authorizationPolicyProvider));
#if NET7_0_OR_GREATER
_rateLimiterPolicyProvider = rateLimiterPolicyProvider ?? throw new ArgumentNullException(nameof(rateLimiterPolicyProvider));
#endif
_corsPolicyProvider = corsPolicyProvider ?? throw new ArgumentNullException(nameof(corsPolicyProvider));
_loadBalancingPolicies = loadBalancingPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(loadBalancingPolicies));
_affinityFailurePolicies = affinityFailurePolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(affinityFailurePolicies));
Expand All @@ -72,6 +81,9 @@ public async ValueTask<IList<Exception>> ValidateRouteAsync(RouteConfig route)

errors.AddRange(_transformBuilder.ValidateRoute(route));
await ValidateAuthorizationPolicyAsync(errors, route.AuthorizationPolicy, route.RouteId);
#if NET7_0_OR_GREATER
await ValidateRateLimiterPolicyAsync(errors, route.RateLimiterPolicy, route.RouteId);
#endif
await ValidateCorsPolicyAsync(errors, route.CorsPolicy, route.RouteId);

if (route.Match is null)
Expand Down Expand Up @@ -287,6 +299,49 @@ private async ValueTask ValidateAuthorizationPolicyAsync(IList<Exception> errors
}
}

#if NET7_0_OR_GREATER
private async ValueTask ValidateRateLimiterPolicyAsync(IList<Exception> errors, string? rateLimiterPolicyName, string routeId)
{
if (string.IsNullOrEmpty(rateLimiterPolicyName))
mburumaxwell marked this conversation as resolved.
Show resolved Hide resolved
{
return;
}

if (string.Equals(RateLimitingConstants.Default, rateLimiterPolicyName, StringComparison.OrdinalIgnoreCase))
{
var policy = await _rateLimiterPolicyProvider.GetPolicyAsync(rateLimiterPolicyName);
if (policy is not null)
{
errors.Add(new ArgumentException($"The application has registered a RateLimiter policy named '{rateLimiterPolicyName}' that conflicts with the reserved RateLimiter policy name used on this route. The registered policy name needs to be changed for this route to function."));
}
return;
}

if (string.Equals(RateLimitingConstants.Disable, rateLimiterPolicyName, StringComparison.OrdinalIgnoreCase))
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
{
var policy = await _rateLimiterPolicyProvider.GetPolicyAsync(rateLimiterPolicyName);
if (policy is not null)
{
errors.Add(new ArgumentException($"The application has registered a RateLimiter policy named '{rateLimiterPolicyName}' that conflicts with the reserved RateLimiter policy name used on this route. The registered policy name needs to be changed for this route to function."));
}
return;
}

try
{
var policy = await _rateLimiterPolicyProvider.GetPolicyAsync(rateLimiterPolicyName);
if (policy is null)
{
errors.Add(new ArgumentException($"RateLimiter policy '{rateLimiterPolicyName}' not found for route '{routeId}'."));
}
}
catch (Exception ex)
{
errors.Add(new ArgumentException($"Unable to retrieve the RateLimiter policy '{rateLimiterPolicyName}' for route '{routeId}'.", ex));
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
}
}
#endif

private async ValueTask ValidateCorsPolicyAsync(IList<Exception> errors, string? corsPolicyName, string routeId)
{
if (string.IsNullOrEmpty(corsPolicyName))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Reflection;
using System.Threading.Tasks;
#if NET7_0_OR_GREATER
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
using Microsoft.AspNetCore.RateLimiting;
#endif
using Microsoft.Extensions.Options;

namespace Yarp.ReverseProxy.Configuration;

// TODO: update this once AspNetCore provides a mechanism to validate the RateLimiter policies https://github.com/dotnet/aspnetcore/issues/45684

#if NET7_0_OR_GREATER

internal interface IYarpRateLimiterPolicyProvider
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
{
ValueTask<object?> GetPolicyAsync(string policyName);
}

internal class YarpRateLimiterPolicyProvider : IYarpRateLimiterPolicyProvider
{
private readonly RateLimiterOptions _rateLimiterOptions;

private readonly System.Collections.IDictionary _policyMap, _unactivatedPolicyMap;
Tratcher marked this conversation as resolved.
Show resolved Hide resolved

public YarpRateLimiterPolicyProvider(IOptions<RateLimiterOptions> rateLimiterOptions)
{
_rateLimiterOptions = rateLimiterOptions?.Value ?? throw new ArgumentNullException(nameof(rateLimiterOptions));

var type = typeof(RateLimiterOptions);
var flags = BindingFlags.Instance | BindingFlags.NonPublic;
_policyMap = (System.Collections.IDictionary)type.GetProperty("PolicyMap", flags)!.GetValue(_rateLimiterOptions, null)!;
_unactivatedPolicyMap = (System.Collections.IDictionary)type.GetProperty("UnactivatedPolicyMap", flags)!.GetValue(_rateLimiterOptions, null)!;
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
}

public ValueTask<object?> GetPolicyAsync(string policyName)
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
{
return ValueTask.FromResult(_policyMap[policyName] ?? _unactivatedPolicyMap[policyName]);
}
}
#endif
10 changes: 10 additions & 0 deletions src/ReverseProxy/Configuration/RateLimitingConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Yarp.ReverseProxy.Configuration;

internal static class RateLimitingConstants
{
internal const string Default = "Default";
internal const string Disable = "Disable";
}
29 changes: 21 additions & 8 deletions src/ReverseProxy/Configuration/RouteConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public sealed record RouteConfig
/// </summary>
public string? AuthorizationPolicy { get; init; }

/// <summary>
/// The name of the RateLimiterPolicy to apply to this route.
/// If not set then only the GlobalLimiter will apply.
/// Set to "Disable" to disable rate limiting for this route.
mburumaxwell marked this conversation as resolved.
Show resolved Hide resolved
/// Set to "Default" or leave empty use the global rate limits, if any.
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
public string? RateLimiterPolicy { get; init; }

/// <summary>
/// The name of the CorsPolicy to apply to this route.
/// If not set then the route won't be automatically matched for cors preflight requests.
Expand Down Expand Up @@ -79,6 +87,7 @@ public bool Equals(RouteConfig? other)
&& string.Equals(RouteId, other.RouteId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(ClusterId, other.ClusterId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(AuthorizationPolicy, other.AuthorizationPolicy, StringComparison.OrdinalIgnoreCase)
&& string.Equals(RateLimiterPolicy, other.RateLimiterPolicy, StringComparison.OrdinalIgnoreCase)
&& string.Equals(CorsPolicy, other.CorsPolicy, StringComparison.OrdinalIgnoreCase)
&& Match == other.Match
&& CaseSensitiveEqualHelper.Equals(Metadata, other.Metadata)
Expand All @@ -87,13 +96,17 @@ public bool Equals(RouteConfig? other)

public override int GetHashCode()
{
return HashCode.Combine(Order,
RouteId?.GetHashCode(StringComparison.OrdinalIgnoreCase),
ClusterId?.GetHashCode(StringComparison.OrdinalIgnoreCase),
AuthorizationPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase),
CorsPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase),
Match,
CaseSensitiveEqualHelper.GetHashCode(Metadata),
CaseSensitiveEqualHelper.GetHashCode(Transforms));
// HashCode.Combine(...) takes only 8 arguments
var hash = new HashCode();
hash.Add(Order);
hash.Add(RouteId?.GetHashCode(StringComparison.OrdinalIgnoreCase));
hash.Add(ClusterId?.GetHashCode(StringComparison.OrdinalIgnoreCase));
hash.Add(AuthorizationPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
hash.Add(RateLimiterPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
hash.Add(CorsPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
hash.Add(Match);
hash.Add(CaseSensitiveEqualHelper.GetHashCode(Metadata));
hash.Add(CaseSensitiveEqualHelper.GetHashCode(Transforms));
return hash.ToHashCode();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ internal static class IReverseProxyBuilderExtensions
{
public static IReverseProxyBuilder AddConfigBuilder(this IReverseProxyBuilder builder)
{
#if NET7_0_OR_GREATER
builder.Services.TryAddSingleton<IYarpRateLimiterPolicyProvider, YarpRateLimiterPolicyProvider>();
#endif
builder.Services.TryAddSingleton<IConfigValidator, ConfigValidator>();
builder.Services.TryAddSingleton<IRandomFactory, RandomFactory>();
builder.AddTransformFactory<ForwardedTransformFactory>();
Expand Down