Skip to content
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
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
}
170 changes: 168 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 @@

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,164 @@
});
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 {
// fall back
}
Comment thread Fixed
Comment on lines +110 to +112

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.

Copilot Autofix

AI 22 days ago

In general, to fix a “generic catch clause” problem, you replace catch { ... } with:

  • One or more specific catch (SomeExpectedException) clauses that represent anticipated, benign failure modes that you want to handle gracefully, and
  • Optionally, a catch (Exception ex) that logs and rethrows or otherwise surfaces truly unexpected failures, instead of silently swallowing them.

For ExtractPropertyFromRequest, the code is trying to extract a form or JSON property in a best-effort manner. Reasonable, expected failures include:

  • Invalid form reading / request state issues (InvalidOperationException, IOException)
  • JSON parsing issues (JsonException)
    These can be treated as “can’t extract property, fall back to null”.

The best minimal change without altering existing behavior too much is:

  • Replace the bare catch with a catch (Exception ex).
  • Inside, log the exception at a low level (e.g., debug or trace) using an ILogger obtained via httpContext.RequestServices.GetService<ILoggerFactory>(), but still fall back by returning null.
    This preserves the functional behavior (swallowing errors and returning null) while making the error diagnosable and avoiding a completely empty generic catch.

Because we are constrained to the shown snippet and this file, we:

  • Add using Microsoft.Extensions.Logging; at the top (a standard, well-known BCL extension library).
  • Modify the catch block at lines 110–112 to accept Exception ex, resolve a logger, and log the exception in a conservative, non-throwing way.
    We do not change the method signature or callers, and we retain the final return null;.

Suggested changeset 1
src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs b/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs
--- a/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs
+++ b/src/Indice.AspNetCore/Extensions/RateLimiterExtensions.cs
@@ -10,6 +10,7 @@
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
 
 namespace Microsoft.Extensions.DependencyInjection;
 
@@ -107,7 +108,14 @@
                     requestBody.Position = 0;
                 }
             }
-        } catch {
+        } catch (Exception ex) {
+            try {
+                var loggerFactory = httpContext.RequestServices.GetService<ILoggerFactory>();
+                var logger = loggerFactory?.CreateLogger("Indice.AspNetCore.RateLimiting");
+                logger?.LogDebug(ex, "Failed to extract partition property '{PartitionByProperty}' from request.", partitionByProperty);
+            } catch {
+                // Ignore logging failures and fall back.
+            }
             // fall back
         }
         return null;
EOF
@@ -10,6 +10,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Microsoft.Extensions.DependencyInjection;

@@ -107,7 +108,14 @@
requestBody.Position = 0;
}
}
} catch {
} catch (Exception ex) {
try {
var loggerFactory = httpContext.RequestServices.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger("Indice.AspNetCore.RateLimiting");
logger?.LogDebug(ex, "Failed to extract partition property '{PartitionByProperty}' from request.", partitionByProperty);
} catch {
// Ignore logging failures and fall back.
}
// fall back
}
return null;
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +110 to +112
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