Skip to content
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
2 changes: 1 addition & 1 deletion API/Controller/Account/CheckUsername.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public sealed partial class AccountController
/// <param name="data"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
[HttpPost("username/check")]
[HttpPost("username/check")] // High-volume endpoint, we don't want to rate limit this
public async Task<UsernameCheckResponse> CheckUsername(ChangeUsernameRequest data, CancellationToken cancellationToken)
{
var result = await _accountService.CheckUsernameAvailabilityAsync(data.Username, cancellationToken);
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/Login.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using OpenShock.Common.Problems;
using OpenShock.Common.Utils;
using System.Net.Mime;
using Microsoft.AspNetCore.RateLimiting;

namespace OpenShock.API.Controller.Account;

Expand All @@ -20,6 +21,7 @@ public sealed partial class AccountController
/// <response code="200">User successfully logged in</response>
/// <response code="401">Invalid username or password</response>
[HttpPost("login")]
[EnableRateLimiting("auth")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Net;
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Services.Account;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
Expand All @@ -22,6 +23,7 @@ public sealed partial class AccountController
/// <response code="200">User successfully logged in</response>
/// <response code="401">Invalid username or password</response>
[HttpPost("login")]
[EnableRateLimiting("auth")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/PasswordResetCheckValid.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
using OpenShock.Common.Models;
Expand All @@ -18,6 +19,7 @@ public sealed partial class AccountController
/// <response code="200">Valid password reset process</response>
/// <response code="404">Password reset process not found</response>
[HttpHead("recover/{passwordResetId}/{secret}")]
[EnableRateLimiting("auth")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound
[MapToApiVersion("1")]
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/PasswordResetComplete.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
using OpenShock.Common.Models;
Expand All @@ -18,6 +19,7 @@ public sealed partial class AccountController
/// <response code="200">Password successfully changed</response>
/// <response code="404">Password reset process not found</response>
[HttpPost("recover/{passwordResetId}/{secret}")]
[EnableRateLimiting("auth")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound
[MapToApiVersion("1")]
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/PasswordResetInitiate.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using OpenShock.Common.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.DataAnnotations;

namespace OpenShock.API.Controller.Account;
Expand All @@ -12,6 +13,7 @@ public sealed partial class AccountController
/// </summary>
/// <response code="200">Password reset email sent if the email is associated to an registered account</response>
[HttpPost("reset")]
[EnableRateLimiting("auth")]
[MapToApiVersion("1")]
public async Task<LegacyEmptyResponse> PasswordResetInitiate([FromBody] ResetRequest body)
{
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/PasswordResetInitiateV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Mvc;
using OpenShock.Common.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.API.Models.Requests;
using OpenShock.API.Services.Account;
using OpenShock.Common.DataAnnotations;
Expand All @@ -20,6 +21,7 @@ public sealed partial class AccountController
/// </summary>
/// <response code="200">Password reset email sent if the email is associated to an registered account</response>
[HttpPost("reset-password")]
[EnableRateLimiting("auth")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.Json)]
[MapToApiVersion("2")]
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/Signup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using OpenShock.API.Models.Requests;
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
using OpenShock.Common.Models;
Expand All @@ -17,6 +18,7 @@ public sealed partial class AccountController
/// <response code="200">User successfully signed up</response>
/// <response code="409">Username or email already exists</response>
[HttpPost("signup")]
[EnableRateLimiting("auth")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailOrUsernameAlreadyExists
[MapToApiVersion("1")]
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Account/SignupV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Net;
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.Turnstile;
Expand All @@ -22,6 +23,7 @@ public sealed partial class AccountController
/// <response code="200">User successfully signed up</response>
/// <response code="400">Username or email already exists</response>
[HttpPost("signup")]
[EnableRateLimiting("auth")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailOrUsernameAlreadyExists
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidTurnstileResponse
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Device/Pair.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Redis.OM;
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.Errors;
using OpenShock.Common.Problems;
using OpenShock.Common.Models;
Expand All @@ -23,6 +24,7 @@ public sealed partial class DeviceController
[MapToApiVersion("1")]
[HttpGet("pair/{pairCode}", Name = "Pair")]
[HttpGet("~/{version:apiVersion}/pair/{pairCode}", Name = "Pair_DEPRECATED")] // Backwards compatibility
[EnableRateLimiting("auth")]
[ProducesResponseType<LegacyDataResponse<string>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PairCodeNotFound
public async Task<IActionResult> Pair([FromRoute] string pairCode)
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Shockers/GetShockerLogs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Net.Mime;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Models.Response;
using OpenShock.Common.Errors;
Expand All @@ -23,6 +24,7 @@ public sealed partial class ShockerController
/// <response code="200">The logs</response>
/// <response code="404">Shocker does not exist</response>
[HttpGet("{shockerId}/logs")]
[EnableRateLimiting("shocker-logs")]
[ProducesResponseType<LegacyDataResponse<IAsyncEnumerable<LogEntry>>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShockerNotFound
[MapToApiVersion("1")]
Expand Down
2 changes: 2 additions & 0 deletions API/Controller/Tokens/ReportTokens.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using OpenShock.Common.Services.Turnstile;
using OpenShock.Common.Utils;
using System.Net;
using Microsoft.AspNetCore.RateLimiting;
using OpenShock.Common.Services.Webhook;

namespace OpenShock.API.Controller.Tokens;
Expand All @@ -24,6 +25,7 @@ public sealed partial class TokensController
/// <param name="cancellationToken"></param>
/// <response code="200">The tokens were deleted if found</response>
[HttpPost("report")]
[EnableRateLimiting("token-reporting")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> ReportTokens(
[FromBody] ReportTokensRequest body,
Expand Down
123 changes: 120 additions & 3 deletions API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
using OpenShock.Common.Services.Ota;
using OpenShock.Common.Services.Turnstile;
using OpenShock.Common.Swagger;
using OpenShock.Common.Utils;
using Serilog;
using System.Net;
using System.Security.Claims;
using System.Threading.RateLimiting;

var builder = OpenShockApplication.CreateDefaultBuilder<Program>(args);

Expand All @@ -34,6 +38,115 @@
builder.Services.AddOpenShockDB(databaseConfig);
builder.Services.AddOpenShockServices();

builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (context, cancellationToken) =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiting");

context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;

if (!context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
retryAfter = TimeSpan.FromMinutes(1);
}

var retryAfterSeconds = Math.Ceiling(retryAfter.TotalSeconds).ToString("R");

context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds;

logger.LogWarning("Rate limit hit. IP: {IP}, Path: {Path}, User: {User}, Retry-After: {RetryAfter}s",
context.HttpContext.Connection.RemoteIpAddress,
context.HttpContext.Request.Path,
context.HttpContext.User.Identity?.Name ?? "Anonymous",
retryAfterSeconds);

await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try again later.", cancellationToken);
};

// Global fallback limiter
// Fixed window at 10k requests allows 20k bursts if burst occurs at window boundry
Comment thread
LucHeart marked this conversation as resolved.
Outdated
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(_ =>
RateLimitPartition.GetSlidingWindowLimiter("global-1m", _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 10_000,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
}));

// Per-IP limiter
options.AddPolicy("per-ip", context =>
{
var ip = context.GetRemoteIP();
if (IPAddress.IsLoopback(ip))
{
return RateLimitPartition.GetNoLimiter(IPAddress.Loopback);
}

return RateLimitPartition.GetSlidingWindowLimiter(ip, _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 1000,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});

// Per-user limiter
options.AddPolicy("per-user", context =>
{
var user = context.User;
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";

if (user.HasClaim(claim => claim is { Type: ClaimTypes.Role, Value: "Admin" or "System" }))
{
return RateLimitPartition.GetNoLimiter($"user-{userId}-privileged");
}

return RateLimitPartition.GetSlidingWindowLimiter($"user-{userId}", _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 600,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});

// Authentication endpoints limiter
options.AddPolicy("auth", context =>
{
var ip = context.GetRemoteIP();
return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
});
});

// Token reporting endpoint concurrency limiter
options.AddPolicy("token-reporting", _ =>
RateLimitPartition.GetConcurrencyLimiter("token-reporting", _ => new ConcurrencyLimiterOptions
{
PermitLimit = 5,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10
}));

// Log fetching endpoint concurrency limiter
options.AddPolicy("shocker-logs", _ =>
RateLimitPartition.GetConcurrencyLimiter("shocker-logs", _ => new ConcurrencyLimiterOptions
{
PermitLimit = 10,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 20
}));
});

builder.Services.AddSignalR()
.AddOpenShockStackExchangeRedis(options => { options.Configuration = redisConfig; })
.AddJsonProtocol(options =>
Expand All @@ -59,7 +172,7 @@

var app = builder.Build();

await app.UseCommonOpenShockMiddleware();
await app.UseCommonOpenShockMiddleware(addRateLimiting: true);

if (!databaseConfig.SkipMigration)
{
Expand All @@ -70,8 +183,12 @@
Log.Warning("Skipping possible database migrations...");
}

app.MapHub<UserHub>("/1/hubs/user", options => options.Transports = HttpTransportType.WebSockets);
app.MapHub<PublicShareHub>("/1/hubs/share/link/{id:guid}", options => options.Transports = HttpTransportType.WebSockets);
app.MapHub<UserHub>("/1/hubs/user", options => options.Transports = HttpTransportType.WebSockets)
.RequireRateLimiting("per-ip")
.RequireRateLimiting("per-user");
app.MapHub<PublicShareHub>("/1/hubs/share/link/{id:guid}", options => options.Transports = HttpTransportType.WebSockets)
.RequireRateLimiting("per-ip")
.RequireRateLimiting("per-user");

await app.RunAsync();

Expand Down
23 changes: 19 additions & 4 deletions Common/OpenShockMiddlewareHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static class OpenShockMiddlewareHelper
ForwardedForHeaderName = "CF-Connecting-IP"
};

public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this WebApplication app)
public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this WebApplication app, bool addRateLimiting)
{
var metricsOptions = app.Services.GetRequiredService<IOptions<MetricsOptions>>().Value;
var metricsAllowedIpNetworks = metricsOptions.AllowedNetworks.Select(x => IPNetwork.Parse(x)).ToArray();
Expand Down Expand Up @@ -70,6 +70,11 @@ public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this
await redisConnection.CreateIndexAsync(typeof(DevicePair));
await redisConnection.CreateIndexAsync(typeof(LcgNode));

if (addRateLimiting)
{
app.UseRateLimiter();
}

app.UseOpenTelemetryPrometheusScrapingEndpoint(context =>
{
if(context.Request.Path != "/metrics") return false;
Expand All @@ -86,10 +91,20 @@ public static async Task<IApplicationBuilder> UseCommonOpenShockMiddleware(this
.AddDocument("1", "Version 1")
.AddDocument("2", "Version 2");

app.MapScalarApiReference("/scalar/viewer", scalarOptions);

app.MapControllers();
var scalarEndpoints = app.MapScalarApiReference("/scalar/viewer", scalarOptions);

var controllerEndpoints = app.MapControllers();

if (addRateLimiting)
{
scalarEndpoints
.RequireRateLimiting("per-ip")
.RequireRateLimiting("per-user");
controllerEndpoints
.RequireRateLimiting("per-ip")
.RequireRateLimiting("per-user");
}

return app;
}

Expand Down
2 changes: 1 addition & 1 deletion Cron/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

var app = builder.Build();

await app.UseCommonOpenShockMiddleware();
await app.UseCommonOpenShockMiddleware(addRateLimiting: false);

var hangfireOptions = new DashboardOptions();
if (app.Environment.IsProduction() || Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true")
Expand Down
2 changes: 1 addition & 1 deletion LiveControlGateway/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@

var app = builder.Build();

await app.UseCommonOpenShockMiddleware();
await app.UseCommonOpenShockMiddleware(addRateLimiting: false);

await app.RunAsync();