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

Client rate limit by body parameter with multiple values. #355

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AspNetCoreRateLimit.sln
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreRateLimit.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreRateLimit.Redis", "src\AspNetCoreRateLimit.Redis\AspNetCoreRateLimit.Redis.csproj", "{45DC4701-59A2-445F-8010-EAB850FF6F2E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreRateLimit.Redis.BodyParameter", "src\AspNetCoreRateLimit.Redis.BodyParameter\AspNetCoreRateLimit.Redis.BodyParameter.csproj", "{E26A2482-DD6E-45E1-90EE-603641BBE34C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -38,6 +40,10 @@ Global
{45DC4701-59A2-445F-8010-EAB850FF6F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45DC4701-59A2-445F-8010-EAB850FF6F2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45DC4701-59A2-445F-8010-EAB850FF6F2E}.Release|Any CPU.Build.0 = Release|Any CPU
{E26A2482-DD6E-45E1-90EE-603641BBE34C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E26A2482-DD6E-45E1-90EE-603641BBE34C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E26A2482-DD6E-45E1-90EE-603641BBE34C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E26A2482-DD6E-45E1-90EE-603641BBE34C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -47,6 +53,7 @@ Global
{14C9FF9D-E50A-4A66-AEAB-F2C113743B95} = {83B05009-1BC7-4E56-8D4B-0E107CA4D45B}
{0A7C0247-AE46-4068-AE42-F955AE2A1D0E} = {83B05009-1BC7-4E56-8D4B-0E107CA4D45B}
{45DC4701-59A2-445F-8010-EAB850FF6F2E} = {909648ED-E1F4-4DAE-A274-AEC47BE826F3}
{E26A2482-DD6E-45E1-90EE-603641BBE34C} = {909648ED-E1F4-4DAE-A274-AEC47BE826F3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {2FE0A4A1-855C-4770-A84F-F1CAE18E62EF}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\AspNetCoreRateLimit.Redis\AspNetCoreRateLimit.Redis.csproj" />
<ProjectReference Include="..\AspNetCoreRateLimit\AspNetCoreRateLimit.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using AspNetCoreRateLimit.Redis.BodyParameter.Core.ProcessingStrategies;
using AspNetCoreRateLimit.Redis.BodyParameter.Models;
using AspNetCoreRateLimit.Redis.BodyParameter.Store;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace AspNetCoreRateLimit.Redis.BodyParameter;

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
public class ClientRateLimitAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
var httpContextAccessor = validationContext.GetService<IHttpContextAccessor>();

if (httpContextAccessor == null)
{
throw new ArgumentNullException(nameof(IHttpContextAccessor), "Don't forget to add service the IHttpContextAccessor.");
}

var clientBodyParameterOptions = validationContext.GetService<IOptions<ClientBodyParameterRateLimitOptions>>();

if (clientBodyParameterOptions == null)
{
throw new ArgumentNullException(nameof(ClientBodyParameterRateLimitOptions));
}

var cacheClientBodyParameterPolicies = validationContext.GetService<IClientBodyParameterPolicyStore>();

if (cacheClientBodyParameterPolicies == null)
{
throw new ArgumentNullException(nameof(IClientBodyParameterPolicyStore));
}

var request = httpContextAccessor.HttpContext?.Request;

if (request == null)
{
throw new ArgumentNullException(nameof(HttpContext));
}

request.Headers.TryGetValue(clientBodyParameterOptions.Value.ClientIdHeader, out var clientId);

if (string.IsNullOrEmpty(clientId)) return ValidationResult.Success;

var clientBodyParameterRateLimitPolicy = cacheClientBodyParameterPolicies.Get($"{clientBodyParameterOptions.Value.ClientPolicyPrefix}_{clientId}");

if (clientBodyParameterRateLimitPolicy == null)
{
throw new Exception($"Cannot found any rules for Client: {clientId}.");
}

var endpoint = $"{request.Method.ToLower()}:{request.Path}";

var endpointRule = clientBodyParameterRateLimitPolicy.Rules.FirstOrDefault(x => x.Endpoint.Equals(endpoint, StringComparison.CurrentCultureIgnoreCase));

if (endpointRule is not { EnableBodyParameter: true }) return ValidationResult.Success;

var bodyParameterRule = endpointRule.BodyParameters.FirstOrDefault(x =>
x.ParameterName.Equals(validationContext.MemberName, StringComparison.CurrentCultureIgnoreCase) &&
x.ParameterValues.Any(y => y == (string)value!));

if (bodyParameterRule == null)
{
throw new Exception($"Cannot found any parameter name or values: [{validationContext.MemberName}: {value}].");
}

var bodyParameterRedisProcessingStrategy = validationContext.GetService<BodyParameterRedisProcessingStrategy>();

if (bodyParameterRedisProcessingStrategy == null)
{
throw new ArgumentNullException(nameof(BodyParameterRedisProcessingStrategy), "Don't forget to add service the '.AddDistributedBodyParameterRateLimitingStores()'.");
}

var key = BuildCounterKey(clientId, clientBodyParameterOptions.Value, endpointRule, bodyParameterRule);

BodyParameterRateLimitCounter rateLimitCounter = bodyParameterRedisProcessingStrategy.ProcessRequest(key, bodyParameterRule);

if (bodyParameterRule.Limit > 0)
{
if (rateLimitCounter.Timestamp + bodyParameterRule.PeriodTimespan.Value < DateTime.UtcNow)
{
// continue
}
else
{
// check if limit is reached
if (rateLimitCounter.Count > bodyParameterRule.Limit)
{
//compute retry after value
var retryAfter = RetryAfterFrom(rateLimitCounter.Timestamp, bodyParameterRule);

if (!endpointRule.MonitorMode)
{
// break execution
var responseErrorMessage = ReturnQuotaExceededResponse(httpContextAccessor.HttpContext, clientBodyParameterOptions.Value, bodyParameterRule, retryAfter);

return new ValidationResult(responseErrorMessage);
}
}
}
}
// if limit is zero or less, block the request.
else
{
if (!endpointRule.MonitorMode)
{
var responseErrorMessage = ReturnQuotaExceededResponse(httpContextAccessor.HttpContext, clientBodyParameterOptions.Value, bodyParameterRule, int.MaxValue.ToString(CultureInfo.InvariantCulture));

return new ValidationResult(responseErrorMessage);
}
}

return ValidationResult.Success;
}

#region Helpers

private static string BuildCounterKey(string clientId, BodyParameterRateLimitOptions rateLimitOptions, BodyParameterRateLimitRule bodyParameterRateLimitRule, EndpointBodyParameterRateLimitRule endpointBodyParameterRateLimitRule)
{
var clientAndEndPointKey = $"{rateLimitOptions.RateLimitCounterPrefix}_{clientId}_{bodyParameterRateLimitRule.Endpoint}";
var parameterKey = $"{endpointBodyParameterRateLimitRule.Period}_{endpointBodyParameterRateLimitRule.ParameterName}_{endpointBodyParameterRateLimitRule.ParameterValues}";
using var algorithm = SHA1.Create();
return $"{Convert.ToBase64String(algorithm.ComputeHash(Encoding.UTF8.GetBytes(clientAndEndPointKey)))}:{Convert.ToBase64String(algorithm.ComputeHash(Encoding.UTF8.GetBytes(parameterKey)))}";
}

private static string RetryAfterFrom(DateTime timestamp, EndpointBodyParameterRateLimitRule rule)
{
var diff = timestamp + rule.PeriodTimespan.Value - DateTime.UtcNow;
var seconds = Math.Max(diff.TotalSeconds, 1);
return $"{seconds:F0}";
}

private static string FormatPeriodTimespan(TimeSpan period)
{
var sb = new StringBuilder();

if (period.Days > 0)
{
sb.Append($"{period.Days}d");
}

if (period.Hours > 0)
{
sb.Append($"{period.Hours}h");
}

if (period.Minutes > 0)
{
sb.Append($"{period.Minutes}m");
}

if (period.Seconds > 0)
{
sb.Append($"{period.Seconds}s");
}

if (period.Milliseconds > 0)
{
sb.Append($"{period.Milliseconds}ms");
}

return sb.ToString();
}

private static string ReturnQuotaExceededResponse(HttpContext httpContext, ClientBodyParameterRateLimitOptions rateLimitOptions, EndpointBodyParameterRateLimitRule rule, string retryAfter)
{
var message = string.Format(
rateLimitOptions.QuotaExceededResponse?.Content ??
rateLimitOptions.QuotaExceededMessage ??
"API parameter calls quota exceeded! maximum admitted {0} per {1}.",
rule.Limit,
rule.PeriodTimespan.HasValue ? FormatPeriodTimespan(rule.PeriodTimespan.Value) : rule.Period, retryAfter);
if (!rateLimitOptions.DisableRateLimitHeaders)
{
httpContext.Response.Headers["Retry-After"] = retryAfter;
httpContext.Response.Headers["X-Enable-Body-Parameter-Rate-Limit"] = "1";
}

return message;
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using AspNetCoreRateLimit.Redis.BodyParameter.Models;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;

namespace AspNetCoreRateLimit.Redis.BodyParameter.Core.ProcessingStrategies
{
public class BodyParameterRedisProcessingStrategy
{
private readonly IConnectionMultiplexer _connectionMultiplexer;
private readonly IRateLimitConfiguration _config;
private readonly ILogger<BodyParameterRedisProcessingStrategy> _logger;

public BodyParameterRedisProcessingStrategy(IConnectionMultiplexer connectionMultiplexer, IRateLimitConfiguration config, ILogger<BodyParameterRedisProcessingStrategy> logger)
{
_connectionMultiplexer = connectionMultiplexer ?? throw new ArgumentException("IConnectionMultiplexer was null. Ensure StackExchange.Redis was successfully registered");
_config = config;
_logger = logger;
}

private static readonly LuaScript AtomicIncrement = LuaScript.Prepare("local count = redis.call(\"INCRBYFLOAT\", @key, tonumber(@delta)) local ttl = redis.call(\"TTL\", @key) if ttl == -1 then redis.call(\"EXPIRE\", @key, @timeout) end return count");

public BodyParameterRateLimitCounter ProcessRequest(string counterId, EndpointBodyParameterRateLimitRule rule)
{
return Increment(counterId, rule.PeriodTimespan.Value, _config.RateIncrementer);
}

private BodyParameterRateLimitCounter Increment(string counterId, TimeSpan interval, Func<double>? rateIncrementer = null)
{
var now = DateTime.UtcNow;
var numberOfIntervals = now.Ticks / interval.Ticks;
var intervalStart = new DateTime(numberOfIntervals * interval.Ticks, DateTimeKind.Utc);

_logger.LogDebug("Calling Lua script. {counterId}, {timeout}, {delta}", counterId, interval.TotalSeconds, 1D);
var count = _connectionMultiplexer.GetDatabase().ScriptEvaluate(AtomicIncrement, new
{
key = new RedisKey(counterId), timeout = interval.TotalSeconds, delta = rateIncrementer?.Invoke() ?? 1D
});
return new BodyParameterRateLimitCounter
{
Count = (double)count,
Timestamp = intervalStart
};
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
{
/// <summary>
/// Stores the initial access time and the numbers of calls made from that point
/// </summary>
public class BodyParameterRateLimitCounter
{
public DateTime Timestamp { get; set; }

public double Count { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Http;

namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
{
public class BodyParameterRateLimitOptions
{
public List<BodyParameterRateLimitRule> GeneralRules { get; set; }

public List<string> EndpointWhitelist { get; set; }

/// <summary>
/// Gets or sets the HTTP header that holds the client identifier, by default is X-ClientId
/// </summary>
public string ClientIdHeader { get; set; } = "X-ClientId";

public List<string> ClientWhitelist { get; set; }

/// <summary>
/// Gets or sets the HTTP header of the real ip header injected by reverse proxy, by default is X-Real-IP
/// </summary>
public string RealIpHeader { get; set; } = "X-Real-IP";

public List<string> IpWhitelist { get; set; }

/// <summary>
/// Gets or sets the HTTP Status code returned when rate limiting occurs, by default value is set to 429 (Too Many Requests)
/// </summary>
public int HttpStatusCode { get; set; } = 429;

/// <summary>
/// Gets or sets a value that will be used as a formatter for the QuotaExceeded response message.
/// If none specified the default will be:
/// API calls quota exceeded! maximum admitted {0} per {1}
/// </summary>
public string QuotaExceededMessage { get; set; }

/// <summary>
/// Gets or sets a model that represents the QuotaExceeded response (content-type, content, status code).
/// </summary>
public QuotaExceededResponse QuotaExceededResponse { get; set; }

/// <summary>
/// Gets or sets the counter prefix, used to compose the rate limit counter cache key
/// </summary>
public string RateLimitCounterPrefix { get; set; } = "crlc";

/// <summary>
/// Gets or sets a value indicating whether all requests, including the rejected ones, should be stacked in this order: day, hour, min, sec
/// </summary>
public bool StackBlockedRequests { get; set; }

/// <summary>
/// Enables endpoint rate limiting based URL path and HTTP verb
/// </summary>
public bool EnableEndpointRateLimiting { get; set; }

/// <summary>
/// Disables X-Rate-Limit and Retry-After headers
/// </summary>
public bool DisableRateLimitHeaders { get; set; }

/// <summary>
/// Enabled the comparison logic to use Regex instead of wildcards.
/// </summary>
public bool EnableRegexRuleMatching { get; set; }

/// <summary>
/// Gets or sets behavior after the request is blocked
/// </summary>
public Func<HttpContext, ClientRequestIdentity, BodyParameterRateLimitCounter, BodyParameterRateLimitRule, Task> RequestBlockedBehaviorAsync { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace AspNetCoreRateLimit.Redis.BodyParameter.Models
{
public class BodyParameterRateLimitPolicy
{
public List<BodyParameterRateLimitRule> Rules { get; set; } = new List<BodyParameterRateLimitRule>();
}
}
Loading