diff --git a/NineChronicles.Headless.Executable/appsettings.json b/NineChronicles.Headless.Executable/appsettings.json index 3e4d70044..8f42ab9db 100644 --- a/NineChronicles.Headless.Executable/appsettings.json +++ b/NineChronicles.Headless.Executable/appsettings.json @@ -109,6 +109,11 @@ "Endpoint": "*:/graphql/stagetransaction", "Period": "60s", "Limit": 12 + }, + { + "Endpoint": "*:/graphql/transactionresults", + "Period": "60s", + "Limit": 60 } ], "QuotaExceededResponse": { @@ -117,6 +122,7 @@ "StatusCode": 429 }, "IpBanThresholdCount": 5, + "TransactionResultsBanThresholdCount": 100, "IpBanMinute" : 60, "IpBanResponse": { "Content": "{ \"message\": \"Your Ip has been banned.\" }", diff --git a/NineChronicles.Headless/Middleware/CustomRateLimitMiddleware.cs b/NineChronicles.Headless/Middleware/CustomRateLimitMiddleware.cs index d390c3c44..c86457be0 100644 --- a/NineChronicles.Headless/Middleware/CustomRateLimitMiddleware.cs +++ b/NineChronicles.Headless/Middleware/CustomRateLimitMiddleware.cs @@ -18,6 +18,7 @@ public class CustomRateLimitMiddleware : RateLimitMiddleware _options; private readonly string _whitelistedIp; + private readonly int _banCount; private readonly System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler _tokenHandler = new(); private readonly Microsoft.IdentityModel.Tokens.TokenValidationParameters _validationParams; @@ -36,6 +37,7 @@ public CustomRateLimitMiddleware(RequestDelegate next, var issuer = jwtConfig["Issuer"] ?? ""; var key = jwtConfig["Key"] ?? ""; _whitelistedIp = configuration.GetSection("IpRateLimiting:IpWhitelist")?.Get()?.FirstOrDefault() ?? "127.0.0.1"; + _banCount = configuration.GetValue("IpRateLimiting:TransactionResultsBanThresholdCount", 100); _validationParams = new Microsoft.IdentityModel.Tokens.TokenValidationParameters { ValidateIssuer = true, @@ -71,6 +73,18 @@ public override async Task ResolveIdentityAsync(HttpConte { identity.Path = "/graphql/stagetransaction"; } + else if (body.Contains("transactionResults")) + { + identity.Path = "/graphql/transactionresults"; + + // Check for txIds count + var txIdsCount = CountTxIds(body); + if (txIdsCount > _banCount) + { + _logger.Information($"[IP-RATE-LIMITER] Banning IP {identity.ClientIp} due to excessive txIds count: {txIdsCount}"); + IpBanMiddleware.BanIp(identity.ClientIp); + } + } } // Check for JWT secret key in headers @@ -110,5 +124,53 @@ public override async Task ResolveIdentityAsync(HttpConte return (headerValues[0], headerValues[1]); } + + private int CountTxIds(string body) + { + try + { + var json = System.Text.Json.JsonDocument.Parse(body); + + // Check for txIds in query variables first + if (json.RootElement.TryGetProperty("variables", out var variables) && + variables.TryGetProperty("txIds", out var txIdsElement) && + txIdsElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + // Count from variables + return txIdsElement.GetArrayLength(); + } + + // Fallback to check the query string if variables are not set + if (json.RootElement.TryGetProperty("query", out var queryElement)) + { + var query = queryElement.GetString(); + if (!string.IsNullOrWhiteSpace(query)) + { + // Extract txIds from the query string using regex + var txIdMatches = System.Text.RegularExpressions.Regex.Matches( + query, @"transactionResults\s*\(\s*txIds\s*:\s*\[(?[^\]]*)\]" + ); + + if (txIdMatches.Count > 0) + { + // Extract the inner contents of txIds + var txIdList = txIdMatches[0].Groups["txIds"].Value; + + // Count txIds using commas + var txIds = txIdList.Split(',', System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries); + + return txIds.Length; + } + } + } + } + catch (System.Exception ex) + { + _logger.Warning("[IP-RATE-LIMITER] Error parsing request body: {Message}", ex.Message); + } + + // Return 0 if txIds not found + return 0; + } } } diff --git a/NineChronicles.Headless/Middleware/HttpCaptureMiddleware.cs b/NineChronicles.Headless/Middleware/HttpCaptureMiddleware.cs index 54a9b89d4..a62e8ab8a 100644 --- a/NineChronicles.Headless/Middleware/HttpCaptureMiddleware.cs +++ b/NineChronicles.Headless/Middleware/HttpCaptureMiddleware.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Serilog; using ILogger = Serilog.ILogger; +using Microsoft.Extensions.Configuration; namespace NineChronicles.Headless.Middleware { @@ -10,20 +11,31 @@ public class HttpCaptureMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; + private readonly bool _enableIpRateLimiting; - public HttpCaptureMiddleware(RequestDelegate next) + public HttpCaptureMiddleware(RequestDelegate next, Microsoft.Extensions.Configuration.IConfiguration configuration) { _next = next; _logger = Log.Logger.ForContext(); + _enableIpRateLimiting = configuration.GetValue("IpRateLimiting:EnableEndpointRateLimiting"); } public async Task InvokeAsync(HttpContext context) { + var remoteIp = context.Connection.RemoteIpAddress!.ToString(); + + // Conditionally skip IP banning if endpoint rate-limiting is disabled + if (_enableIpRateLimiting && IpBanMiddleware.IsIpBanned(remoteIp)) + { + _logger.Information($"[GRAPHQL-REQUEST-CAPTURE] Skipping logging for banned IP: {remoteIp}"); + await _next(context); // Continue the request pipeline + return; + } + // Prevent to harm HTTP/2 communication. if (context.Request.Protocol == "HTTP/1.1") { context.Request.EnableBuffering(); - var remoteIp = context.Connection.RemoteIpAddress; var body = await new StreamReader(context.Request.Body).ReadToEndAsync(); context.Items["RequestBody"] = body; _logger.Information("[GRAPHQL-REQUEST-CAPTURE] IP: {IP} Method: {Method} Endpoint: {Path} {Body}", diff --git a/NineChronicles.Headless/Middleware/IpBanMiddleware.cs b/NineChronicles.Headless/Middleware/IpBanMiddleware.cs index 8f391e56e..6210cd39d 100644 --- a/NineChronicles.Headless/Middleware/IpBanMiddleware.cs +++ b/NineChronicles.Headless/Middleware/IpBanMiddleware.cs @@ -39,6 +39,16 @@ public static void UnbanIp(string ip) } } + public static bool IsIpBanned(string ip) + { + if (_bannedIps.ContainsKey(ip)) + { + return true; + } + + return false; + } + public Task InvokeAsync(HttpContext context) { var remoteIp = context.Connection.RemoteIpAddress!.ToString();