From 362aac87ec38c16fa8330abdfcd50a8cc23fa82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 2 Jun 2026 13:46:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v3.16.2=20=E2=80=94=20per-policy=20rate?= =?UTF-8?q?-limiter=20StatusCode/StatusMessage=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each policy under RateLimiterOptions:Policies may now set its own StatusCode and/or StatusMessage; the top-level values become global defaults that a policy inherits when it omits either field. The override is resolved at rejection time from the endpoint's policy name (EnableRateLimitingAttribute), since the framework exposes only a single global OnRejected/RejectionStatusCode. Fully backward compatible. Also ships a ready-to-use disabled login_throttle default policy (10/min per client IP with its own rejection message). --- NpgsqlRest/NpgsqlRest.csproj | 8 +- NpgsqlRestClient/Builder.cs | 69 ++++++++++-- NpgsqlRestClient/ConfigDefaults.cs | 31 ++++++ NpgsqlRestClient/ConfigSchemaGenerator.cs | 4 + NpgsqlRestClient/ConfigTemplate.cs | 31 ++++++ NpgsqlRestClient/NpgsqlRestClient.csproj | 2 +- NpgsqlRestClient/appsettings.json | 31 ++++++ .../AuthTests/RateLimiterPerPolicyTests.cs | 102 ++++++++++++++++++ .../ConfigTests/ConfigValidationTests.cs | 24 +++++ NpgsqlRestTests/Setup/Program.cs | 10 +- .../Setup/RateLimiterPerPolicyTestFixture.cs | 99 +++++++++++++++++ changelog/v3.16.2.md | 68 ++++++++++++ npm/package.json | 2 +- 13 files changed, 462 insertions(+), 19 deletions(-) create mode 100644 NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs create mode 100644 NpgsqlRestTests/Setup/RateLimiterPerPolicyTestFixture.cs create mode 100644 changelog/v3.16.2.md diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj index ad48a8a2..db97b44f 100644 --- a/NpgsqlRest/NpgsqlRest.csproj +++ b/NpgsqlRest/NpgsqlRest.csproj @@ -29,10 +29,10 @@ true README.MD bin\$(Configuration)\$(AssemblyName).xml - 3.16.1 - 3.16.1 - 3.16.1 - 3.16.1 + 3.16.2 + 3.16.2 + 3.16.2 + 3.16.2 true diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 12166c10..e6c677ab 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -2379,16 +2379,16 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren var defaultPolicy = _config.GetConfigStr("DefaultPolicy", rateLimiterCfg); var message = _config.GetConfigStr("StatusMessage", rateLimiterCfg); + var globalStatusCode = _config.GetConfigInt("StatusCode", rateLimiterCfg) ?? 429; + // Per-policy StatusCode/StatusMessage overrides, keyed by policy name (the dict key). The top-level + // StatusCode/StatusMessage stay as the global defaults; an individual policy may override either one + // independently. The override that applies to a rejected request is resolved at rejection time from + // the endpoint's policy name (see ApplyRateLimiterRejectionAsync) — the framework's single global + // OnRejected/RejectionStatusCode otherwise fire for every policy regardless of which bucket tripped. + var rejectionOverrides = new Dictionary(); Instance.Services.AddRateLimiter(options => { - options.RejectionStatusCode = _config.GetConfigInt("StatusCode", rateLimiterCfg) ?? 429; - if (string.IsNullOrEmpty(message) is false) - { - options.OnRejected = async (context, cancellationToken) => - { - await context.HttpContext.Response.WriteAsync(message, cancellationToken); - }; - } + options.RejectionStatusCode = globalStatusCode; foreach (var sectionCfg in policiesCfg.GetChildren()) { // Detect legacy array form (children keyed by index "0", "1", ...) and fail loudly. @@ -2416,6 +2416,14 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren // Policy name comes from the dict key (e.g. "fixed", "sliding"). var name = sectionCfg.Key; + // Optional per-policy rejection overrides. Either may be omitted to inherit the global value. + var policyStatusCode = _config.GetConfigInt("StatusCode", sectionCfg); + var policyMessage = _config.GetConfigStr("StatusMessage", sectionCfg); + if (policyStatusCode.HasValue || string.IsNullOrEmpty(policyMessage) is false) + { + rejectionOverrides[name] = (policyStatusCode, policyMessage); + } + // Read optional partition configuration. When present, every request resolves a partition key // and gets its own bucket. When null, all requests share a single global bucket (legacy behavior). var partition = ReadPartitionConfig(sectionCfg); @@ -2566,11 +2574,56 @@ public enum RateLimiterType { FixedWindow, SlidingWindow, TokenBucket, Concurren FormatPartitionForLog(partition)); } } + + // Install a single global OnRejected only when there is something to write or override: either a + // global StatusMessage, or at least one policy carrying its own StatusCode/StatusMessage. When + // neither is present, leaving OnRejected unset preserves the legacy empty-body rejection. + if (string.IsNullOrEmpty(message) is false || rejectionOverrides.Count > 0) + { + var overrides = rejectionOverrides.ToFrozenDictionary(); + options.OnRejected = (context, cancellationToken) => + ApplyRateLimiterRejectionAsync(context.HttpContext, overrides, globalStatusCode, message, cancellationToken); + } }); return (defaultPolicy, true); } + /// + /// Resolves and writes a rate-limiter rejection response, honoring per-policy StatusCode/ + /// StatusMessage overrides. The framework exposes only one global OnRejected/ + /// RejectionStatusCode, so this looks up the policy that rejected the current request via the + /// endpoint's and applies that policy's override when present, + /// otherwise falling back to the global status code and message. The status code is set inside + /// OnRejected (which runs after the framework has applied RejectionStatusCode), so a + /// per-policy code wins. Public so the test fixtures can drive the exact production wiring. + /// + public static ValueTask ApplyRateLimiterRejectionAsync( + Microsoft.AspNetCore.Http.HttpContext httpContext, + FrozenDictionary overrides, + int globalStatusCode, + string? globalMessage, + CancellationToken cancellationToken) + { + int? statusCode = null; + string? messageText = null; + var policyName = httpContext.GetEndpoint()?.Metadata.GetMetadata()?.PolicyName; + if (policyName is not null && overrides.TryGetValue(policyName, out var ov)) + { + statusCode = ov.StatusCode; + messageText = ov.Message; + } + + httpContext.Response.StatusCode = statusCode ?? globalStatusCode; + + var finalMessage = messageText ?? globalMessage; + if (string.IsNullOrEmpty(finalMessage) is false) + { + return new ValueTask(httpContext.Response.WriteAsync(finalMessage, cancellationToken)); + } + return ValueTask.CompletedTask; + } + /// /// Parses an optional Partition sub-section under a rate-limiter policy. Returns null if the /// section is absent or contains no usable sources/flags. Logs Warning for individual invalid sources. diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index af70ebfa..127e93f2 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -36,6 +36,14 @@ private static JsonArray CreatePartitionSourcesArray() return arr; } + // IP-only partition sources for the default login_throttle policy (one bucket per client IP). + private static JsonArray CreateIpPartitionSourcesArray() + { + var arr = new JsonArray(); + arr.Add((JsonNode?)new JsonObject { ["Type"] = "IpAddress" }); + return arr; + } + /// /// Returns a JsonObject containing all default configuration values. /// @@ -682,6 +690,21 @@ private static JsonObject GetRateLimiterOptionsDefaults() ["Sources"] = CreatePartitionSourcesArray(), ["BypassAuthenticated"] = false } + }, + ["login_throttle"] = new JsonObject + { + ["Type"] = "FixedWindow", + ["Enabled"] = false, + ["PermitLimit"] = 10, + ["WindowSeconds"] = 60, + ["QueueLimit"] = 0, + ["AutoReplenishment"] = true, + ["StatusMessage"] = "Too many login attempts. Please wait a minute and try again.", + ["Partition"] = new JsonObject + { + ["Sources"] = CreateIpPartitionSourcesArray(), + ["BypassAuthenticated"] = false + } } }; @@ -1415,6 +1438,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a ["WindowSeconds"] = 60, ["QueueLimit"] = 10, ["AutoReplenishment"] = true, + ["StatusCode"] = null, + ["StatusMessage"] = null, ["Partition"] = PartitionSchema() }; } @@ -1429,6 +1454,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a ["SegmentsPerWindow"] = 6, ["QueueLimit"] = 10, ["AutoReplenishment"] = true, + ["StatusCode"] = null, + ["StatusMessage"] = null, ["Partition"] = PartitionSchema() }; } @@ -1443,6 +1470,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a ["ReplenishmentPeriodSeconds"] = 10, ["QueueLimit"] = 10, ["AutoReplenishment"] = true, + ["StatusCode"] = null, + ["StatusMessage"] = null, ["Partition"] = PartitionSchema() }; } @@ -1455,6 +1484,8 @@ public static List FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a ["PermitLimit"] = 10, ["QueueLimit"] = 5, ["OldestFirst"] = true, + ["StatusCode"] = null, + ["StatusMessage"] = null, ["Partition"] = PartitionSchema() }; } diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 15866fe2..e782d9eb 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -273,9 +273,13 @@ public static partial class ConfigSchemaGenerator ["ValidationOptions"] = "Parameter validation options for validating endpoint parameters before database execution.\nValidation rules can be referenced in comment annotations using \"validate _param using rule_name\" syntax.", ["ValidationOptions:Rules"] = "Named validation rules that can be referenced in comment annotations.\nDefault rules: not_null, not_empty, required, email\n\nEach rule can have:\n- Type: NotNull, NotEmpty, Required, Regex, MinLength, MaxLength\n- Pattern: Regular expression pattern for Regex type\n- MinLength: Minimum length for MinLength type\n- MaxLength: Maximum length for MaxLength type\n- Message: Error message with placeholders {0}=original name, {1}=converted name, {2}=rule name\n- StatusCode: HTTP status code to return (default: 400)", ["RateLimiterOptions"] = "Rate Limiter settings to limit the number of requests from clients.", + ["RateLimiterOptions:StatusCode"] = "Global HTTP status code returned for rejected (rate-limited) requests. Default 429. Individual policies may override this via `RateLimiterOptions:Policies::StatusCode`.", + ["RateLimiterOptions:StatusMessage"] = "Global response body text returned for rejected (rate-limited) requests. When empty, the rejection has an empty body. Individual policies may override this via `RateLimiterOptions:Policies::StatusMessage`.", ["RateLimiterOptions:Policies"] = "Named rate-limiter policies. The object key is the policy name (referenced from endpoints via the `rate_limiter_policy ` comment annotation, or used as `DefaultPolicy`). Each entry has a `Type` and type-specific tuning fields. Set `Enabled: true` per policy to register it at startup.", ["RateLimiterOptions:Policies:Type"] = "Rate-limiter algorithm:\n- FixedWindow: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#fixed\n- SlidingWindow: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#sliding-window-limiter\n- TokenBucket: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#token-bucket-limiter\n- Concurrency: see https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit#concurrency-limiter", ["RateLimiterOptions:Policies:Enabled"] = "Set to true to register this policy at startup. Disabled policies are skipped.", + ["RateLimiterOptions:Policies:StatusCode"] = "Optional per-policy override of the rejection HTTP status code. When set, requests rejected by this policy return this code instead of the global `RateLimiterOptions:StatusCode`. Omit to inherit the global value.", + ["RateLimiterOptions:Policies:StatusMessage"] = "Optional per-policy override of the rejection response body. When set, requests rejected by this policy return this text instead of the global `RateLimiterOptions:StatusMessage`. Omit to inherit the global value.", ["RateLimiterOptions:Policies:Partition"] = "Optional partitioning. When set, each request resolves a partition key (per-user via claim, per-IP, per-header, or static fallback) and gets its own rate-limit bucket. Without this, all requests under this policy share a single global bucket. Combine `Sources` (ordered list of key sources) with optional `BypassAuthenticated` (true → authenticated users skip rate limiting entirely).", ["RateLimiterOptions:Policies:Partition:Sources"] = "Ordered list of partition-key sources. Walked top-to-bottom at request time; the first source returning a non-empty key wins. If no source matches, the fallback key `unpartitioned` is used. Each source has a `Type` (Claim, IpAddress, Header, Static) and source-dependent `Name` or `Value`.", ["RateLimiterOptions:Policies:Partition:Sources:Type"] = "Source of the partition key:\n- Claim: read `User.FindFirst(Name)?.Value` (Name = claim type)\n- IpAddress: read the client IP via `Request.GetClientIpAddress()` — honors `X-Forwarded-For`, `X-Real-IP`, etc. before falling back to `Connection.RemoteIpAddress`\n- Header: read `Request.Headers[Name]` (Name = header name)\n- Static: always returns `Value` (use as a terminal fallback)", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 3861ccdd..19381525 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1665,6 +1665,9 @@ public static partial class ConfigSchemaGenerator // "RateLimiterOptions": { "Enabled": false, + // Global defaults for rejected (rate-limited) requests. Each policy below may override either of these + // independently via its own "StatusCode"/"StatusMessage" — useful when one policy guards logins and + // another guards a public API and each needs its own message. A policy that omits them inherits these. "StatusCode": 429, "StatusMessage": "Too many requests. Please try again later.", "DefaultPolicy": null, @@ -1675,6 +1678,10 @@ public static partial class ConfigSchemaGenerator // Each policy has a `Type` of FixedWindow, SlidingWindow, TokenBucket, or Concurrency, and a set of // type-specific tuning fields. Set `"Enabled": true` to register the policy at startup. // + // Each policy may also set its own `StatusCode` and/or `StatusMessage` to override the global values + // above for requests rejected by that specific policy. Omit either to inherit the global default. The + // "fixed" policy below shows the commented-out fields. + // // **Breaking change in 3.13.0**: this section was previously an array of objects with explicit `"Name"` // properties. It is now an object keyed by policy name, matching `ValidationOptions:Rules` and // `CacheOptions:Profiles`. Migrate by moving each policy's `Name` value to be the JSON key and dropping @@ -1689,6 +1696,10 @@ public static partial class ConfigSchemaGenerator "WindowSeconds": 60, "QueueLimit": 10, "AutoReplenishment": true + // Override the global rejection response for this policy only (omit to inherit the global values): + // "StatusCode": 429, + // "StatusMessage": "Too many requests for this endpoint. Please slow down.", + // // To partition this policy (per-user, per-IP, etc.) so each request gets its own bucket // instead of sharing a single global one, add a "Partition" block. See the "per_user" // policy below for a complete example. @@ -1752,6 +1763,26 @@ public static partial class ConfigSchemaGenerator ], "BypassAuthenticated": false } + }, + // + // Ready-to-use login throttle: 10 attempts per minute per client IP, with its own rejection + // message. Apply it to a login endpoint with `rate_limiter login_throttle` (or set it as + // DefaultPolicy). Set "Enabled": true to activate. + // + "login_throttle": { + "Type": "FixedWindow", + "Enabled": false, + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0, + "AutoReplenishment": true, + "StatusMessage": "Too many login attempts. Please wait a minute and try again.", + "Partition": { + "Sources": [ + { "Type": "IpAddress" } + ], + "BypassAuthenticated": false + } } } }, diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index b2752e9b..0c5e869d 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -10,7 +10,7 @@ true true full - 3.16.1 + 3.16.2 diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index d222ecf8..6989ad58 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1656,6 +1656,9 @@ // "RateLimiterOptions": { "Enabled": false, + // Global defaults for rejected (rate-limited) requests. Each policy below may override either of these + // independently via its own "StatusCode"/"StatusMessage" — useful when one policy guards logins and + // another guards a public API and each needs its own message. A policy that omits them inherits these. "StatusCode": 429, "StatusMessage": "Too many requests. Please try again later.", "DefaultPolicy": null, @@ -1666,6 +1669,10 @@ // Each policy has a `Type` of FixedWindow, SlidingWindow, TokenBucket, or Concurrency, and a set of // type-specific tuning fields. Set `"Enabled": true` to register the policy at startup. // + // Each policy may also set its own `StatusCode` and/or `StatusMessage` to override the global values + // above for requests rejected by that specific policy. Omit either to inherit the global default. The + // "fixed" policy below shows the commented-out fields. + // // **Breaking change in 3.13.0**: this section was previously an array of objects with explicit `"Name"` // properties. It is now an object keyed by policy name, matching `ValidationOptions:Rules` and // `CacheOptions:Profiles`. Migrate by moving each policy's `Name` value to be the JSON key and dropping @@ -1680,6 +1687,10 @@ "WindowSeconds": 60, "QueueLimit": 10, "AutoReplenishment": true + // Override the global rejection response for this policy only (omit to inherit the global values): + // "StatusCode": 429, + // "StatusMessage": "Too many requests for this endpoint. Please slow down.", + // // To partition this policy (per-user, per-IP, etc.) so each request gets its own bucket // instead of sharing a single global one, add a "Partition" block. See the "per_user" // policy below for a complete example. @@ -1743,6 +1754,26 @@ ], "BypassAuthenticated": false } + }, + // + // Ready-to-use login throttle: 10 attempts per minute per client IP, with its own rejection + // message. Apply it to a login endpoint with `rate_limiter login_throttle` (or set it as + // DefaultPolicy). Set "Enabled": true to activate. + // + "login_throttle": { + "Type": "FixedWindow", + "Enabled": false, + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0, + "AutoReplenishment": true, + "StatusMessage": "Too many login attempts. Please wait a minute and try again.", + "Partition": { + "Sources": [ + { "Type": "IpAddress" } + ], + "BypassAuthenticated": false + } } } }, diff --git a/NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs b/NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs new file mode 100644 index 00000000..96b4137b --- /dev/null +++ b/NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs @@ -0,0 +1,102 @@ +using NpgsqlRestTests.Setup; + +namespace NpgsqlRestTests; + +public static partial class Database +{ + /// + /// SQL setup for the per-policy rejection-message tests. Three trivial functions, each tied to one of + /// the three policies registered by : + /// + /// - rlpm_login() → policy "rlpm-login" (message override, status inherits global 429) + /// - rlpm_strict() → policy "rlpm-strict" (status 503 + message override) + /// - rlpm_inherit() → policy "rlpm-inherit" (no override; inherits global 429 + global message) + /// + /// Each policy has PermitLimit=1, so the first call returns "ok" and the second returns the configured + /// rejection. Each policy is exercised by exactly one test, so the single shared bucket is not contended. + /// + public static void RateLimiterPerPolicyTests() + { + script.Append(""" + + create function rlpm_login() + returns text language sql as $$ select 'ok' $$; + comment on function rlpm_login() is ' + HTTP GET + rate_limiter rlpm-login + '; + + create function rlpm_strict() + returns text language sql as $$ select 'ok' $$; + comment on function rlpm_strict() is ' + HTTP GET + rate_limiter rlpm-strict + '; + + create function rlpm_inherit() + returns text language sql as $$ select 'ok' $$; + comment on function rlpm_inherit() is ' + HTTP GET + rate_limiter rlpm-inherit + '; +"""); + } +} + +[Collection("RateLimiterPerPolicyTestFixture")] +public class RateLimiterPerPolicyTests(RateLimiterPerPolicyTestFixture test) +{ + /// + /// Policy with a StatusMessage override but no StatusCode override: the rejection body is the policy's + /// own message, while the status code falls back to the global 429. + /// + [Fact] + public async Task Policy_with_message_override_returns_its_own_message_and_global_status() + { + using var client = test.CreateClient(); + + using var ok = await client.GetAsync("/api/rlpm-login"); + ok.StatusCode.Should().Be(HttpStatusCode.OK); + (await ok.Content.ReadAsStringAsync()).Should().Be("ok"); + + using var rejected = await client.GetAsync("/api/rlpm-login"); + rejected.StatusCode.Should().Be(HttpStatusCode.TooManyRequests); + (await rejected.Content.ReadAsStringAsync()).Should().Be(RateLimiterPerPolicyTestFixture.LoginMessage); + } + + /// + /// Policy overriding BOTH status and message: the rejection returns 503 and the policy's message, + /// not the global 429. + /// + [Fact] + public async Task Policy_with_status_and_message_override_returns_both() + { + using var client = test.CreateClient(); + + using var ok = await client.GetAsync("/api/rlpm-strict"); + ok.StatusCode.Should().Be(HttpStatusCode.OK); + (await ok.Content.ReadAsStringAsync()).Should().Be("ok"); + + using var rejected = await client.GetAsync("/api/rlpm-strict"); + rejected.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + (await rejected.Content.ReadAsStringAsync()).Should().Be(RateLimiterPerPolicyTestFixture.StrictMessage); + } + + /// + /// Policy with no override inherits the global status code and global message — proving the per-policy + /// overrides do not leak across policies and the global default still applies. + /// + [Fact] + public async Task Policy_without_override_inherits_global_status_and_message() + { + using var client = test.CreateClient(); + + using var ok = await client.GetAsync("/api/rlpm-inherit"); + ok.StatusCode.Should().Be(HttpStatusCode.OK); + (await ok.Content.ReadAsStringAsync()).Should().Be("ok"); + + using var rejected = await client.GetAsync("/api/rlpm-inherit"); + rejected.StatusCode.Should().Be(HttpStatusCode.TooManyRequests); + (await rejected.Content.ReadAsStringAsync()).Should().Be(RateLimiterPerPolicyTestFixture.GlobalMessage); + } +} diff --git a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs index 0c764b5c..770b70ec 100644 --- a/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs +++ b/NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs @@ -365,6 +365,30 @@ public void RateLimiter_ExampleNames_StillValidate() warnings.Should().BeEmpty(); } + [Fact] + public void RateLimiter_PerPolicyStatusOverrides_AcceptsKeys() + { + // Per-policy StatusCode/StatusMessage overrides (3.16.2) must validate cleanly on every Type. + var actual = ActualFromJson(""" + { + "RateLimiterOptions": { + "StatusCode": 429, + "StatusMessage": "Global.", + "Policies": { + "login_throttle": { "Type": "FixedWindow", "PermitLimit": 10, "WindowSeconds": 60, "StatusCode": 429, "StatusMessage": "Too many logins." }, + "search_window": { "Type": "SlidingWindow", "PermitLimit": 50, "WindowSeconds": 60, "SegmentsPerWindow": 6, "StatusMessage": "Slow down." }, + "api_burst": { "Type": "TokenBucket", "TokenLimit": 100, "TokensPerPeriod": 10, "ReplenishmentPeriodSeconds": 1, "StatusCode": 503 }, + "compute": { "Type": "Concurrency", "PermitLimit": 4, "QueueLimit": 0, "OldestFirst": true, "StatusCode": 503, "StatusMessage": "Busy." } + } + } + } + """); + + var warnings = ConfigDefaults.FindUnknownConfigKeys(Defaults(), actual); + + warnings.Should().BeEmpty(); + } + [Fact] public void RateLimiter_TypoInsidePolicy_Flagged() { diff --git a/NpgsqlRestTests/Setup/Program.cs b/NpgsqlRestTests/Setup/Program.cs index 3ebc6f5d..10756de7 100644 --- a/NpgsqlRestTests/Setup/Program.cs +++ b/NpgsqlRestTests/Setup/Program.cs @@ -195,11 +195,11 @@ public static void Main() //SchemaSimilarTo = "custom_param_schema", IncludeSchemas = ["public", "custom_param_schema", "my_schema", "custom_table_param_schema", "tsclient_test", "polp_schema"], // Exclude cp_* / cpx_* (owned by CacheProfilesTestFixture), rlpt_* (owned by - // RateLimiterPartitionTestFixture), and apt_* (owned by AuthSchemeTestFixture). All - // reference fixture-specific configuration that is not present here (cache profiles, - // rate-limiter policies named "rlpt-...", auth profiles), so picking them up in the - // default fixture would fail at startup. - NameNotSimilarTo = "(cp[_x]|rlpt[_]|ast[_]|cab[_]|cabx[_]|sset[_])%", + // RateLimiterPartitionTestFixture), rlpm_* (owned by RateLimiterPerPolicyTestFixture), and + // apt_* (owned by AuthSchemeTestFixture). All reference fixture-specific configuration that + // is not present here (cache profiles, rate-limiter policies named "rlpt-..."/"rlpm-...", + // auth profiles), so picking them up in the default fixture would fail at startup. + NameNotSimilarTo = "(cp[_x]|rlpt[_]|rlpm[_]|ast[_]|cab[_]|cabx[_]|sset[_])%", // TsClient configuration for testing - uses tsclient_module annotations for per-function files EndpointCreateHandlers = [ diff --git a/NpgsqlRestTests/Setup/RateLimiterPerPolicyTestFixture.cs b/NpgsqlRestTests/Setup/RateLimiterPerPolicyTestFixture.cs new file mode 100644 index 00000000..a134c7fc --- /dev/null +++ b/NpgsqlRestTests/Setup/RateLimiterPerPolicyTestFixture.cs @@ -0,0 +1,99 @@ +using System.Collections.Frozen; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using NpgsqlRestClient; + +namespace NpgsqlRestTests.Setup; + +[CollectionDefinition("RateLimiterPerPolicyTestFixture")] +public class RateLimiterPerPolicyTestFixtureCollection : ICollectionFixture { } + +/// +/// End-to-end fixture verifying per-policy StatusCode/StatusMessage overrides for rejected +/// (rate-limited) requests. The framework exposes only a single global OnRejected/ +/// RejectionStatusCode, so resolves the policy +/// that rejected the current request (via the endpoint's ) and +/// applies that policy's override, falling back to the global values when a policy has none. This fixture +/// wires the limiter the exact way does and exercises the real helper. +/// +/// Three FixedWindow policies, each PermitLimit=1 and a 10-minute window (no replenishment during a run), +/// so the second call to a policy reliably returns its configured rejection: +/// - rlpm-login → override message only (status inherits global 429) +/// - rlpm-strict → override BOTH status (503) and message +/// - rlpm-inherit → no override; inherits the global 429 + global message +/// +public class RateLimiterPerPolicyTestFixture : IDisposable +{ + public const string GlobalMessage = "Global rate limit hit."; + public const string LoginMessage = "Too many login attempts. Please wait a minute and try again."; + public const string StrictMessage = "Service busy. Retry shortly."; + + private readonly WebApplication _app; + + public string ServerAddress { get; } + + public RateLimiterPerPolicyTestFixture() + { + var connectionString = Database.Create(); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + // Per-policy overrides keyed by policy name — mirrors the FrozenDictionary BuildRateLimiter assembles + // from each policy's StatusCode/StatusMessage. "rlpm-inherit" is intentionally absent so it falls back. + var overrides = new Dictionary + { + ["rlpm-login"] = (null, LoginMessage), + ["rlpm-strict"] = (503, StrictMessage) + }.ToFrozenDictionary(); + + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = (context, cancellationToken) => + Builder.ApplyRateLimiterRejectionAsync( + context.HttpContext, overrides, StatusCodes.Status429TooManyRequests, GlobalMessage, cancellationToken); + + foreach (var name in new[] { "rlpm-login", "rlpm-strict", "rlpm-inherit" }) + { + options.AddFixedWindowLimiter(name, config => + { + config.PermitLimit = 1; + config.Window = TimeSpan.FromMinutes(10); + config.QueueLimit = 0; + config.AutoReplenishment = false; + }); + } + }); + + _app = builder.Build(); + _app.UseRateLimiter(); + + _app.UseNpgsqlRest(new(connectionString) + { + IncludeSchemas = ["public"], + NameSimilarTo = "rlpm[_]%", + CommentsMode = CommentsMode.ParseAll, + RequiresAuthorization = false, + }); + + _app.StartAsync().GetAwaiter().GetResult(); + + ServerAddress = _app.Urls.First(); + } + + public HttpClient CreateClient() => + new() { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) }; + +#pragma warning disable CA1816 + public void Dispose() +#pragma warning restore CA1816 + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/changelog/v3.16.2.md b/changelog/v3.16.2.md new file mode 100644 index 00000000..30254202 --- /dev/null +++ b/changelog/v3.16.2.md @@ -0,0 +1,68 @@ +# Changelog v3.16.2 (2026-06-02) + +## Version [3.16.2](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.16.2) (2026-06-02) + +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.16.1...3.16.2) + +Patch release that makes the rate-limiter rejection **status code and message overridable per policy**. Previously `RateLimiterOptions:StatusCode` and `RateLimiterOptions:StatusMessage` were the *only* values returned for a rejected request, applied globally regardless of which policy tripped. A config like a `login_throttle` policy with the message *"Too many login attempts…"* would return that same login-specific text for **every** rate-limited endpoint, even ones that have nothing to do with logins. + +## What changed + +### Per-policy `StatusCode` / `StatusMessage` overrides + +Each named policy under `RateLimiterOptions:Policies` may now set its own `StatusCode` and/or `StatusMessage`: + +```jsonc +"RateLimiterOptions": { + "Enabled": true, + "StatusCode": 429, // global default + "StatusMessage": "Too many requests. Please slow down.", + "Policies": { + "login_throttle": { + "Type": "FixedWindow", + "PermitLimit": 10, + "WindowSeconds": 60, + "StatusMessage": "Too many login attempts. Please wait a minute and try again.", + "Partition": { "Sources": [ { "Type": "IpAddress" } ] } + }, + "api": { + "Type": "TokenBucket", + "StatusCode": 503, + "StatusMessage": "API capacity reached. Retry shortly." + } + } +} +``` + +A request rejected by a given policy now returns that policy's status code and message; a policy that omits either field inherits the global value. The override that applies is resolved at rejection time from the endpoint's rate-limiter policy name, so it is correct even though ASP.NET Core exposes only a single global `OnRejected`/`RejectionStatusCode`. + +This is **fully backward compatible**: configs that set only the global `StatusCode`/`StatusMessage` behave exactly as before — those values simply become the defaults that policies may override. + +### New ready-to-use `login_throttle` default policy + +The shipped `appsettings.json` now includes a disabled (`"Enabled": false`) `login_throttle` policy — 10 attempts per minute partitioned per client IP, with its own rejection message — so the common case is one flag away: + +```jsonc +"login_throttle": { + "Type": "FixedWindow", + "Enabled": false, + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0, + "AutoReplenishment": true, + "StatusMessage": "Too many login attempts. Please wait a minute and try again.", + "Partition": { "Sources": [ { "Type": "IpAddress" } ], "BypassAuthenticated": false } +} +``` + +Apply it to a login endpoint with the `rate_limiter login_throttle` comment annotation (or set it as `DefaultPolicy`). + +## Test coverage + +`NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs` (fixture `RateLimiterPerPolicyTestFixture`) boots the limiter through the same wiring `BuildRateLimiter` emits and drives the real `Builder.ApplyRateLimiterRejectionAsync` helper over HTTP, asserting: + +- a policy with a message-only override returns its own message but the global status code, +- a policy overriding both returns its own status code (503) and message, +- a policy with no override inherits the global status code and message. + +Config-key validation for the new per-policy `StatusCode`/`StatusMessage` keys is covered in `ConfigTests/ConfigValidationTests.cs`. diff --git a/npm/package.json b/npm/package.json index daa179b8..d2203c67 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "npgsqlrest", - "version": "3.16.1", + "version": "3.16.2", "description": "Automatic REST API for PostgreSQL Databases Client Build", "scripts": { "postinstall": "node postinstall.js",