Skip to content

rate limit sample #13

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

Merged
merged 17 commits into from
Aug 19, 2022
Merged
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home2" asp-action="Privacy">MVC Privacy</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="/b"> /b</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="/c"> /c</a>
</li>
</ul>
<partial name="_LoginPartial" />
</div>
Expand Down
271 changes: 271 additions & 0 deletions fundamentals/middleware/rate-limit/WebRateLimitAuth/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
#define FIXED // FIRST ADMIN
#if NEVER
#elif FIXED
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

static string GetTicks() => DateTime.Now.Ticks.ToString().Substring(14);

This comment was marked as resolved.

This comment was marked as resolved.


var fixedPolicy = "fixed";

app.UseRateLimiter(new RateLimiterOptions()
.AddFixedWindowLimiter(policyName: fixedPolicy,
new FixedWindowRateLimiterOptions(permitLimit: 2,
window: TimeSpan.FromSeconds(5),
queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 2)));

app.MapGet("/", () => Results.Ok($"Hello {GetTicks()}"))
.RequireRateLimiting(fixedPolicy);

app.Run();
#elif FIRST
// <snippet_1>
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Identity.Client;
using System.Globalization;
using System.Net;
using System.Threading.RateLimiting;
using WebRateLimitAuth;
using WebRateLimitAuth.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ??
throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

// <snippet>
app.UseAuthentication();
app.UseAuthorization();

var userPolicyName = "user";
var completePolicyName = "complete";
var helloPolicy = "hello";

var options = new RateLimiterOptions()
{
OnRejected = (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
}

context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context?.HttpContext?.RequestServices?.GetService<ILoggerFactory>()?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think theses null checks are necessary.

Suggested change
context?.HttpContext?.RequestServices?.GetService<ILoggerFactory>()?
context.HttpContext.RequestServices.GetService<ILoggerFactory>()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
They all have Dereference of possibly null reference.

.CreateLogger("Microsoft.AspNetCore.RateLimitingMiddleware")
.LogWarning($"OnRejected: {GetUserEndPoint(context.HttpContext)}");

return new ValueTask();
}
}
.AddPolicy<string>(completePolicyName,
new SampleRateLimiterPolicy(NullLogger<SampleRateLimiterPolicy>.Instance))
.AddPolicy<string, SampleRateLimiterPolicy>(helloPolicy)
.AddPolicy<string>(userPolicyName, context =>
{
if (context.User?.Identity?.IsAuthenticated is not true)
{
var username = "anonymous user";

return RateLimitPartition.CreateSlidingWindowLimiter<string>(username,
key => new SlidingWindowRateLimiterOptions(
permitLimit: 12,
queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 0,
window: TimeSpan.FromSeconds(5),
segmentsPerWindow: 3
));
}
else
{
return RateLimitPartition.CreateNoLimiter<string>(string.Empty);
}
});

options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, IPAddress>(context =>
{
IPAddress? remoteIPaddress = context?.Connection?.RemoteIpAddress;

if (!IPAddress.IsLoopback(remoteIPaddress!))
{
return RateLimitPartition.CreateTokenBucketLimiter<IPAddress>
(remoteIPaddress!, key =>
new TokenBucketRateLimiterOptions(tokenLimit: 25,
queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1,
replenishmentPeriod: TimeSpan.FromSeconds(15),
tokensPerPeriod: 1,
autoReplenishment: true));
}
else
{
return RateLimitPartition.CreateNoLimiter<IPAddress>(IPAddress.Loopback);
}
});

app.UseRateLimiter(options);

app.MapRazorPages().RequireRateLimiting(userPolicyName);
app.MapDefaultControllerRoute();

static string GetUserEndPoint(HttpContext context) =>
$"User {context.User?.Identity?.Name ?? "Anonymous"} endpoint: {context.Request.Path}" +
$" {context.Connection.RemoteIpAddress}";
static string GetTicks() => DateTime.Now.Ticks.ToString().Substring(14);

app.MapGet("/a", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
.RequireRateLimiting(userPolicyName);

app.MapGet("/b", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
.RequireRateLimiting(completePolicyName);

app.MapGet("/c", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}")
.RequireRateLimiting(helloPolicy);

app.MapGet("/d", (HttpContext context) => $"{GetUserEndPoint(context)} {GetTicks()}");

app.Run();
// </snippet>
// </snippet_1>
#elif ADMIN
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using System.Threading.RateLimiting;
using WebRateLimitAuth.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization(o => o.AddPolicy("AdminsOnly",
b => b.RequireClaim("admin", "true")));

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options =>
options.SignIn.RequireConfirmedAccount = true)
.AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

// <snippet_adm>
app.UseAuthentication();
app.UseAuthorization();

var getPolicyName = "get";
var adminPolicyName = "admin";
var postPolicyName = "post";

app.UseRateLimiter(new RateLimiterOptions()
.AddConcurrencyLimiter(policyName: getPolicyName,
new ConcurrencyLimiterOptions(permitLimit: 2,
queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 2))
.AddNoLimiter(policyName: adminPolicyName)
.AddPolicy(policyName: postPolicyName, partitioner: httpContext =>
{
var accessToken = httpContext?.Features?.Get<IAuthenticateResultFeature>()?
.AuthenticateResult?.Properties?.GetTokenValue("access_token")?.ToString()

This comment was marked as resolved.

?? string.Empty;
if (!StringValues.IsNullOrEmpty(accessToken))
{
return RateLimitPartition.CreateTokenBucketLimiter( accessToken, key =>
new TokenBucketRateLimiterOptions(tokenLimit: 50,
queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1,
replenishmentPeriod: TimeSpan.FromSeconds(5),
tokensPerPeriod: 1,
autoReplenishment: true));
}
else
{
return RateLimitPartition.CreateTokenBucketLimiter("Anon", key =>
new TokenBucketRateLimiterOptions(tokenLimit: 5,
queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1,
replenishmentPeriod: TimeSpan.FromSeconds(5),
tokensPerPeriod: 1,
autoReplenishment: true));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about to use a NoLimiter here? an unauthenticated user could call this endpoint so many times and block all unauthenticated enpoints for everyone.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point and something I'll call out. A DOS attack is something to consider on all limiters and with no limiter. I think the simplest samples I just added will have hard coded limits, the last two sample will use configuration.

}
}));

static string GetUserEndPointMethod(HttpContext context) =>
$"Hello {context.User?.Identity?.Name ?? "Anonymous"} " +
$"Endpoint:{context.Request.Path} Method: {context.Request.Method}";


app.MapGet("/test", (HttpContext context) => $"{GetUserEndPointMethod(context)}")
.RequireRateLimiting(getPolicyName);

app.MapGet("/admin", context => context.Response.WriteAsync("/admin"))
.RequireRateLimiting(adminPolicyName)
.RequireAuthorization("AdminsOnly");

app.MapPost("/post", () => Results.Ok("/post"))
.RequireRateLimiting(postPolicyName);

app.MapRazorPages().RequireRateLimiting(getPolicyName)
.RequireRateLimiting(postPolicyName);

app.MapDefaultControllerRoute();

app.Run();
// </snippet_adm>
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

namespace WebRateLimitAuth;

public class SampleRateLimiterPolicy : IRateLimiterPolicy<string>
{
private Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected;

public SampleRateLimiterPolicy(ILogger<SampleRateLimiterPolicy> logger)
{
_onRejected = (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;
logger.LogWarning($"Request rejected by {nameof(SampleRateLimiterPolicy)}");
return ValueTask.CompletedTask;
};
}

public Func<OnRejectedContext, CancellationToken, ValueTask>?
OnRejected { get => _onRejected; }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it OK to have OnRejected here and also in RateLimiterOptions directly?

This comment was marked as resolved.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, policy OnRejected will win.


public RateLimitPartition<string> GetPartition(HttpContext httpContext)
{
return RateLimitPartition.CreateSlidingWindowLimiter<string>(string.Empty,
key => new SlidingWindowRateLimiterOptions(
permitLimit: 1,
queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 2,
window: TimeSpan.FromSeconds(5),
segmentsPerWindow: 1));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0-preview.6.22330.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0-preview.6.22330.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0-preview.6.22330.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.0-preview.6.22329.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0-preview.6.22329.4" />
<PackageReference Include="Microsoft.AspNetCore.RateLimiting" Version="7.0.0-preview.6.22330.3" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0-preview.7.22376.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0-preview.7.22376.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0-preview.7.22376.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.0-preview.7.22376.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0-preview.7.22376.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.RateLimiting" Version="7.0.0-preview.7.22376.6" />
</ItemGroup>

</Project>