Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d345ebb
[identity] Add per-property partitioning to rate limiter
travlos Mar 17, 2026
a26e754
Merge branch 'develop' into feature/identity/rate-limit
travlos Mar 17, 2026
3fe5f90
Copilot code fixes
travlos Mar 17, 2026
f5a616e
Merge branch 'feature/identity/rate-limit' of https://github.com/indi…
travlos Mar 17, 2026
8792948
Enhance rate limiter with flexible partition strategies
travlos Mar 19, 2026
db375a9
Apply suggestions from code review
travlos Mar 19, 2026
581effa
Potential fix for pull request finding
travlos Mar 20, 2026
15421a2
Merge branch 'develop' into feature/identity/rate-limit
travlos Mar 31, 2026
6c3e5fc
Add support for multiple rate limiting policies per endpoint
travlos Apr 2, 2026
07df33e
Refactor MultiRateLimiterServiceExtensions for clarity
travlos Apr 3, 2026
4ded3fe
Add multi-policy rate limiting filters and attributes
travlos Apr 8, 2026
7d92f12
Merge branch 'develop' into feature/identity/rate-limit
travlos Apr 15, 2026
5b9fd0d
Merge branch 'develop' into feature/identity/rate-limit
travlos Apr 16, 2026
1372926
Merge branch 'develop' into feature/identity/rate-limit
travlos Apr 23, 2026
d5950f6
Merge branch 'develop' into feature/identity/rate-limit
travlos May 5, 2026
022f292
Merge branch 'develop' into feature/identity/rate-limit
travlos May 8, 2026
1ed6e23
Potential fix for pull request finding 'Generic catch clause'
travlos May 8, 2026
647fd78
Remove Middleware
travlos May 8, 2026
8125172
Merge branch 'develop' into feature/identity/rate-limit
travlos May 28, 2026
601128f
Merge branch 'develop' into feature/identity/rate-limit
travlos Jul 21, 2026
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
44 changes: 39 additions & 5 deletions src/Indice.AspNetCore/Configuration/RateLimiterOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,22 @@ public class RateLimiterOptions
/// <summary>List of all rate limiter policies. This is used to ensure that all policies are registered in the rate limiter middleware.</summary>
public IReadOnlyList<string> AllRateLimiterPolicies { get; set; } = Array.Empty<string>();

/// <summary>Custom factory function for creating policy-specific rate limiter rules. Set this to provide custom configurations based on policy names.</summary>
public Func<string, RateLimiterEndpointRule>? CustomPolicyFactory { get; set; }
/// <summary>
/// Custom factory function for creating policy-specific collections of rate limiter rules.
/// Set this to provide custom configurations based on policy names.
/// Return an empty list to indicate that the policy has no active rate limiting rules.
/// Returning <c>null</c> will cause <see cref="GetPolicySettings(string)"/> to fall back to a single default rule.
/// </summary>
public Func<string, List<RateLimiterEndpointRule>>? CustomPolicyFactory { get; set; }

/// <summary>Default configuration for <see cref="RateLimiterEndpointRule"/>. Returns custom rule if <see cref="CustomPolicyFactory"/> is set, otherwise returns a default rule.</summary>
/// <summary>
/// Gets the configured <see cref="RateLimiterEndpointRule"/> list for the specified policy.
/// If <see cref="CustomPolicyFactory"/> is set, its result is returned (including an empty list, which means no rules are applied).
/// If the factory is not set or returns <c>null</c>, a list containing a single default rule is returned.
/// </summary>
/// <param name="policyName">The policy name to get the configuration for.</param>
public RateLimiterEndpointRule GetPolicySettings(string policyName) =>
CustomPolicyFactory?.Invoke(policyName) ?? new();
public List<RateLimiterEndpointRule> GetPolicySettings(string policyName) =>
CustomPolicyFactory?.Invoke(policyName) ?? [new()];
}

/// <summary>Rate limiter fixed window options for Server API.</summary>
Expand All @@ -44,6 +53,18 @@ public class RateLimiterEndpointRule

/// <summary>The Http method of the endpoint to apply the rate limiter. Optional.</summary>
public string? HttpMethod { get; set; }
/// <summary>
/// The property path to extract from the request body for partitioning (e.g., input name for form "Input.Email" , or property name for json "email").
/// When specified, rate limiting will be applied per unique value of this property instead of per IP or user.
/// Optional.
/// </summary>
public string? PartitionByProperty { get; set; }
Comment thread
travlos marked this conversation as resolved.

/// <summary>
/// The partitioning strategy to use for rate limiting. Determines how requests are grouped.
/// Defaults to <see cref="RateLimiterPartitionStrategy.Auto"/> (User subject if authenticated, then request property (if specified), otherwise IP address).
/// </summary>
public RateLimiterPartitionStrategy PartitionStrategy { get; set; } = RateLimiterPartitionStrategy.Auto;

/// <summary>Determines whether <see cref="HttpMethod"/> has a value.</summary>
public bool HasHttpMehtod => !string.IsNullOrWhiteSpace(HttpMethod);
Expand All @@ -52,3 +73,16 @@ public class RateLimiterEndpointRule
public bool CanLimitHttpMethod(string? httpMethod) =>
!HasHttpMehtod || string.Equals(HttpMethod, httpMethod, StringComparison.OrdinalIgnoreCase);
}

/// <summary>Defines the strategy for partitioning rate limit requests.</summary>
public enum RateLimiterPartitionStrategy
{
/// <summary>Automatically determine: User subject if authenticated, then request property (if specified), otherwise IP address.</summary>
Auto = 0,
/// <summary>Partition by IP address.</summary>
IpAddress = 1,
/// <summary>Partition by authenticated user subject claim.</summary>
User = 2,
/// <summary>Partition by a property extracted from the request body (requires <see cref="RateLimiterEndpointRule.PartitionByProperty"/>).</summary>
RequestProperty = 3
}
180 changes: 178 additions & 2 deletions src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.RateLimiting;
using System.Globalization;
using Indice.AspNetCore.Configuration;
using Indice.Security;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

Expand All @@ -26,13 +31,14 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service

services.AddRateLimiter(options => {
foreach (var endpoint in rateLimiterOptions.AllRateLimiterPolicies) {
var endpointOptions = rateLimiterOptions.Rules.FirstOrDefault(rule => rule.Endpoint == endpoint) ?? rateLimiterOptions.GetPolicySettings(endpoint);
var defaultPolicies = rateLimiterOptions.GetPolicySettings(endpoint);
var endpointOptions = rateLimiterOptions.Rules.FirstOrDefault(rule => rule.Endpoint == endpoint) ?? defaultPolicies.First();
options.AddPolicy(endpoint, context => {
if (!endpointOptions.CanLimitHttpMethod(context.Request.Method)) {
return RateLimitPartition.GetNoLimiter("NoRateLimiting");
}
Comment thread
travlos marked this conversation as resolved.
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.User.FindFirstValue(rateLimiterOptions.UserIdentifierClaimType) ?? context.Connection.RemoteIpAddress?.ToString() ?? context.Request.Headers.Host.ToString(),
partitionKey: GetPartitionKey(context, rateLimiterOptions.UserIdentifierClaimType, endpointOptions),
factory: _ => new FixedWindowRateLimiterOptions {
PermitLimit = endpointOptions.PermitLimit.GetValueOrDefault(),
QueueLimit = endpointOptions.QueueLimit.GetValueOrDefault(),
Expand All @@ -51,4 +57,174 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service
});
return services;
}
/// <summary>
/// Helper method to determine the partition key based on strategy, user claims, request body, or fallback to IP/Host
/// </summary>
/// <param name="httpContext">The HTTP context.</param>
/// <param name="userIdentifierClaimType">The claim type to use for identifying the user.</param>
/// <param name="rule">The rate limiter endpoint rule.</param>
/// <returns>The partition key.</returns>
public static string GetPartitionKey(HttpContext httpContext, string userIdentifierClaimType, RateLimiterEndpointRule rule) {
var strategy = rule.PartitionStrategy;

switch (strategy) {
case RateLimiterPartitionStrategy.IpAddress:
return httpContext.Connection.RemoteIpAddress?.ToString() ?? httpContext.Request.Headers.Host.ToString();
case RateLimiterPartitionStrategy.RequestProperty:
if (string.IsNullOrEmpty(rule.PartitionByProperty)) {
return httpContext.Connection.RemoteIpAddress?.ToString() ?? httpContext.Request.Headers.Host.ToString();
}
return ExtractPropertyFromRequest(httpContext, rule.PartitionByProperty)
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? httpContext.Request.Headers.Host.ToString();

case RateLimiterPartitionStrategy.User:
case RateLimiterPartitionStrategy.Auto:
default:
return httpContext.User.FindFirstValue(userIdentifierClaimType)
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? httpContext.Request.Headers.Host.ToString();
Comment thread
travlos marked this conversation as resolved.
}
}

private static string? ExtractPropertyFromRequest(HttpContext httpContext, string partitionByProperty) {
httpContext.Request.EnableBuffering();
try {
// Handle form data
if (httpContext.Request.HasFormContentType) {
if (httpContext.Request.Form.TryGetValue(partitionByProperty, out var propertyValue)) {
return NormalizePartitionKey(propertyValue.ToString());
}
}
// Handle JSON content
else if (httpContext.Request.ContentType != null && httpContext.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) {
var requestBody = httpContext.Request.Body;
requestBody.Position = 0;
try {
var property = FindPropertyValue(requestBody, partitionByProperty).GetAwaiter().GetResult();
return NormalizePartitionKey(property);
} finally {
requestBody.Position = 0;
}
Comment thread
travlos marked this conversation as resolved.
}
} catch (JsonException) {
// fall back
} catch (InvalidDataException) {
// fall back
} catch (IOException) {
// fall back
} catch (UnauthorizedAccessException) {
// fall back
} catch (NotSupportedException) {
// fall back
} catch (InvalidOperationException) {
// fall back
}
return null;
}

/// <summary>
/// Efficiently reads a JSON stream and finds the value of the specified (dot-separated) property path
/// without deserializing the entire payload.
/// </summary>
private static async Task<string?> FindPropertyValue(Stream jsonStream, string property) {
string[] path = property.Split('.');
const int chunkSize = 4096;
byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(chunkSize);

try {
int bytesInBuffer = 0;
int bytesRead;
var state = new JsonReaderState();
int matchIndex = 0;
int currentPathDepth = 0;

while ((bytesRead = await jsonStream.ReadAsync(buffer.AsMemory(bytesInBuffer, buffer.Length - bytesInBuffer))) > 0) {
bytesInBuffer += bytesRead;
bool isFinalBlock = bytesRead == 0 || jsonStream.Position == jsonStream.Length;

var reader = new Utf8JsonReader(buffer.AsSpan(0, bytesInBuffer), isFinalBlock, state);

while (reader.Read()) {
if (reader.TokenType == JsonTokenType.PropertyName) {
if (matchIndex < path.Length && reader.ValueTextEquals(path[matchIndex])) {
matchIndex++;
currentPathDepth = reader.CurrentDepth;

if (matchIndex == path.Length) {
if (reader.Read()) {
string? result = reader.TokenType switch {
JsonTokenType.String => reader.GetString(),
JsonTokenType.Number => reader.GetDouble().ToString(CultureInfo.InvariantCulture),
JsonTokenType.True => "true",
JsonTokenType.False => "false",
JsonTokenType.Null => null,
_ => reader.GetString()
};
return result;
Comment thread
travlos marked this conversation as resolved.
}
} else {
reader.Read();
if (reader.TokenType == JsonTokenType.StartArray) {
continue;
}
}
} else if (reader.CurrentDepth <= currentPathDepth && matchIndex > 0) {
matchIndex = 0;
currentPathDepth = 0;
}
}
}

state = reader.CurrentState;
int bytesConsumed = (int)reader.BytesConsumed;

buffer.AsSpan(bytesConsumed, bytesInBuffer - bytesConsumed).CopyTo(buffer);
bytesInBuffer -= bytesConsumed;

if (bytesInBuffer == buffer.Length) {
byte[] newBuffer = System.Buffers.ArrayPool<byte>.Shared.Rent(buffer.Length * 2);
buffer.AsSpan(0, bytesInBuffer).CopyTo(newBuffer);
System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
buffer = newBuffer;
}

if (isFinalBlock) break;
}

return null;
} finally {
System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
}
}

/// <summary>
/// Normalizes a raw partition key value by trimming, checking for emptiness, and bounding its length.
/// Long values are replaced with a fixed-length SHA-256 hash to keep the key size bounded.
/// </summary>
/// <param name="rawKey">The raw key value extracted from the request or user claims.</param>
/// <returns>A normalized, bounded key suitable for use as a partition key, or <c>null</c> if not usable.</returns>
private static string? NormalizePartitionKey(string? rawKey) {
if (string.IsNullOrWhiteSpace(rawKey)) {
return null;
}
var trimmed = rawKey.Trim();
if (trimmed.Length == 0) {
return null;
}
// If the key is already reasonably small, use it as-is.
const int MaxKeyLength = 128;
if (trimmed.Length <= MaxKeyLength) {
return trimmed;
}
// For very long keys, use a fixed-length hash to bound the size.
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(trimmed);
var hash = sha256.ComputeHash(bytes);
var builder = new StringBuilder(hash.Length * 2);
foreach (var b in hash) {
builder.Append(b.ToString("x2"));
}
Comment thread
travlos marked this conversation as resolved.
return builder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using Indice.AspNetCore.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Threading.RateLimiting;

namespace Indice.AspNetCore.Features.MultiRateLimiter;

/// <summary>
/// Service that manages rate limiting policies and stores Rate Limiters in memory.
/// </summary>
public interface IMultiRateLimiterService
{
/// <summary>
/// Adds a rate limiting policy.
/// </summary>
/// <param name="policyName">The name of the policy.</param>
/// <param name="configurePolicy">Function that configures the rate limit partition for the policy.</param>
void AddPolicy(string policyName, Func<HttpContext, RateLimitPartition<string>> configurePolicy);

/// <summary>
/// Gets a rate limiting policy by name.
/// </summary>
/// <param name="policyName">The name of the policy.</param>
/// <returns>The policy function if found, otherwise null.</returns>
Func<HttpContext, RateLimitPartition<string>>? GetPolicy(string policyName);

/// <summary>
/// Gets all registered policy names.
/// </summary>
/// <returns>A read-only collection of policy names.</returns>
IReadOnlyCollection<string> GetPolicyNames();

/// <summary>
/// Returns the Rate Limiter associated with a key. Creates it first if it doesn't exist.
/// </summary>
/// <param name="key">The identifier used to store the Rate Limiter.</param>
/// <param name="factory">Function that creates the Rate Limiter.</param>
public System.Threading.RateLimiting.RateLimiter GetOrCreateLimiter(string key, Func<System.Threading.RateLimiting.RateLimiter> factory);
}


/// <summary>
/// Service that manages rate limiting policies and stores Rate Limiters in memory.
/// </summary>
public class MultiRateLimiterService : IMultiRateLimiterService
{
private static readonly ConcurrentDictionary<string, System.Threading.RateLimiting.RateLimiter> _limiters = new();
private readonly ConcurrentDictionary<string, Func<HttpContext, RateLimitPartition<string>>> _policies = new();


/// <summary>
/// Helper extension to add policies to the service with a rule.
/// </summary>
public void AddPolicy(string policyName, RateLimiterEndpointRule rule, string userIdentifierClaimType = ClaimTypes.NameIdentifier)
{
AddPolicy(policyName, context =>
{
if (!rule.CanLimitHttpMethod(context.Request.Method))
{
return RateLimitPartition.GetNoLimiter("NoRateLimiting");
}

return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: RateLimiterExtensions.GetPartitionKey(context, userIdentifierClaimType, rule),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = rule.PermitLimit ?? 10,
QueueLimit = rule.QueueLimit ?? 0,
QueueProcessingOrder = rule.QueueProcessingOrder ?? QueueProcessingOrder.OldestFirst,
Window = rule.Window ?? TimeSpan.FromMinutes(1),
AutoReplenishment = true
});
});
}

/// <summary>
/// Adds a rate limiting policy.
/// </summary>
/// <param name="policyName">The name of the policy.</param>
/// <param name="configurePolicy">Function that configures the rate limit partition for the policy.</param>
public void AddPolicy(string policyName, Func<HttpContext, RateLimitPartition<string>> configurePolicy)
{
_policies[policyName] = configurePolicy;
}

/// <summary>
/// Gets a rate limiting policy by name.
/// </summary>
/// <param name="policyName">The name of the policy.</param>
/// <returns>The policy function if found, otherwise null.</returns>
public Func<HttpContext, RateLimitPartition<string>>? GetPolicy(string policyName)
{
return _policies.TryGetValue(policyName, out var policy) ? policy : null;
}

/// <summary>
/// Gets all registered policy names.
/// </summary>
/// <returns>A read-only collection of policy names.</returns>
public IReadOnlyCollection<string> GetPolicyNames()
{
return _policies.Keys.ToList().AsReadOnly();
}

/// <summary>
/// Returns the Rate Limiter associated with a key. Creates it first if it doesn't exist.
/// </summary>
/// <param name="key">The identifier used to store the Rate Limiter.</param>
/// <param name="factory">Function that creates the Rate Limiter.</param>
public System.Threading.RateLimiting.RateLimiter GetOrCreateLimiter(string key, Func<System.Threading.RateLimiting.RateLimiter> factory)
{
return _limiters.GetOrAdd(key, _ => factory());
}
}
Loading
Loading