| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Net.Sockets; |
| | | 4 | | using System.Threading.RateLimiting; |
| | | 5 | | using ArturRios.Data.Relational.Core.DependencyInjection; |
| | | 6 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 8 | | using ArturRios.Heimdall.WebApi.Security; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using ArturRios.Util.WebApi.Configuration; |
| | | 11 | | using ArturRios.Util.WebApi.Security.Extensions; |
| | | 12 | | using Microsoft.AspNetCore.RateLimiting; |
| | | 13 | | using Serilog; |
| | | 14 | | |
| | | 15 | | namespace ArturRios.Heimdall.WebApi; |
| | | 16 | | |
| | | 17 | | public static class RateLimitingConfiguration |
| | | 18 | | { |
| | | 19 | | /// <summary> |
| | | 20 | | /// Rate-limiting policy name applied via <c>[EnableRateLimiting(AuthEndpointRateLimitPolicy)]</c> |
| | | 21 | | /// to <c>AuthController</c>'s anonymous, credential-checking endpoints. Public so the |
| | | 22 | | /// controller and this configuration stay in sync without duplicating the literal. |
| | | 23 | | /// </summary> |
| | | 24 | | public const string AuthEndpointRateLimitPolicy = "AuthAnonymous"; |
| | | 25 | | |
| | | 26 | | /// <summary> |
| | | 27 | | /// Rate-limiting policy name for the endpoints that create a person with a password — each of |
| | | 28 | | /// which derives an Argon2id hash — partitioned by the acting person rather than by address |
| | | 29 | | /// (see <see cref="AddAccountCreationRateLimiting" />). |
| | | 30 | | /// </summary> |
| | | 31 | | public const string AccountCreationRateLimitPolicy = "AccountCreation"; |
| | | 32 | | |
| | | 33 | | /// <summary>Requests per minute one actor may make to the account-creation endpoints.</summary> |
| | | 34 | | public const int AccountCreationPermitLimit = 30; |
| | | 35 | | |
| | | 36 | | /// <summary> |
| | | 37 | | /// Length, in seconds, of the <c>AuthAnonymous</c> fixed window. One value for the limiter's |
| | | 38 | | /// <c>Window</c> and for the <c>Retry-After</c> a rejection falls back to, so the two cannot |
| | | 39 | | /// drift apart. |
| | | 40 | | /// </summary> |
| | | 41 | | private const int AuthEndpointRateLimitWindowSeconds = 60; |
| | | 42 | | |
| | | 43 | | /// <summary> |
| | | 44 | | /// The IPv6 prefix one rate-limit partition covers (see <see cref="RateLimitPartitionKey" />). |
| | | 45 | | /// A whole number of bytes. |
| | | 46 | | /// </summary> |
| | | 47 | | private const int RateLimitIPv6PrefixLength = 56; |
| | | 48 | | |
| | | 49 | | /// <summary> |
| | | 50 | | /// The partition a caller's requests are counted in: the whole address for IPv4, and the |
| | | 51 | | /// <c>/56</c> it belongs to for IPv6. |
| | | 52 | | /// </summary> |
| | | 53 | | /// <remarks> |
| | | 54 | | /// <para> |
| | | 55 | | /// A /64 is the least one IPv6 subscriber is handed, and every address inside it is theirs |
| | | 56 | | /// to use — 2<sup>64</sup> of them. Counted per address, a single caller could give every |
| | | 57 | | /// request a fresh partition and never meet the limit at all. Counted per /64 it is no |
| | | 58 | | /// better for the many subscribers routinely handed a /56: 256 partitions each, enough on |
| | | 59 | | /// their own to keep the password-hash gate full. A /56 is one caller here. |
| | | 60 | | /// </para> |
| | | 61 | | /// <para> |
| | | 62 | | /// Coarser is not safer: a /48 is a whole site, often many unrelated people behind one |
| | | 63 | | /// provider, and they would share one budget of ten a minute. A flood from many unrelated |
| | | 64 | | /// networks is not bounded here at all — that is for the edge (Threat Model TH-03). An |
| | | 65 | | /// IPv4-mapped IPv6 address is one IPv4 caller, and is counted as that address. |
| | | 66 | | /// </para> |
| | | 67 | | /// </remarks> |
| | | 68 | | public static string RateLimitPartitionKey(IPAddress? address) |
| | | 69 | | { |
| | 833 | 70 | | if (address is null) |
| | | 71 | | { |
| | 670 | 72 | | return "unknown"; |
| | | 73 | | } |
| | | 74 | | |
| | 163 | 75 | | if (address.IsIPv4MappedToIPv6) |
| | | 76 | | { |
| | 2 | 77 | | address = address.MapToIPv4(); |
| | | 78 | | } |
| | | 79 | | |
| | 163 | 80 | | if (address.AddressFamily != AddressFamily.InterNetworkV6) |
| | | 81 | | { |
| | 139 | 82 | | return address.ToString(); |
| | | 83 | | } |
| | | 84 | | |
| | 24 | 85 | | var bytes = address.GetAddressBytes(); |
| | 24 | 86 | | Array.Clear(bytes, RateLimitIPv6PrefixLength / 8, 16 - RateLimitIPv6PrefixLength / 8); |
| | | 87 | | |
| | 24 | 88 | | return $"{new IPAddress(bytes)}/{RateLimitIPv6PrefixLength}"; |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | /// <summary> |
| | | 92 | | /// Whether a request spends the account-creation address bound: only an administrator's |
| | | 93 | | /// person access token — the full sign-in audience, neither a two-factor challenge nor a |
| | | 94 | | /// subject-rights token (<see cref="IdentityUserMapper" /> has already held each token's marks |
| | | 95 | | /// to its audience) — and only the roles the four endpoints admit. Anything else is turned |
| | | 96 | | /// away by the endpoint's own checks and must not use up a bound administrators share. |
| | | 97 | | /// </summary> |
| | | 98 | | private static bool CountsAgainstCreationAddress(IdentityUser? actor) => |
| | 199 | 99 | | actor is { MfaPending: false, SubjectRightsOnly: false } && |
| | 199 | 100 | | actor.RoleId is (int)Roles.SystemAdmin or (int)Roles.ScopeAdmin; |
| | | 101 | | |
| | | 102 | | /// <summary> |
| | | 103 | | /// Throttles the three endpoints that create a person with a password (<c>POST /api/persons</c>, |
| | | 104 | | /// <c>POST /api/scopes/{id}/persons</c>, <c>POST /api/scopes/{id}/owners</c>), and the one that invites |
| | | 105 | | /// an existing administrator and mails them (<c>POST /api/scopes/{id}/owners/{personId}</c>), twice over: |
| | | 106 | | /// <see cref="AccountCreationPermitLimit" /> a minute per acting person, and as many per address. |
| | | 107 | | /// The first three derive an Argon2id hash through <c>PasswordHashGate</c>, and each needs only an |
| | | 108 | | /// administrator's token, so without a bound one caller could keep the gate full and starve every |
| | | 109 | | /// sign-in (Threat Model TH-03); the fourth would otherwise let one caller fill an inbox. |
| | | 110 | | /// </summary> |
| | | 111 | | /// <remarks> |
| | | 112 | | /// <para> |
| | | 113 | | /// The actor bound is the endpoint policy. The address bound — the partition the |
| | | 114 | | /// credential endpoints use (<see cref="AuthRateLimitPartition" />), in a namespace of its |
| | | 115 | | /// own — is the global limiter, which ASP.NET Core applies before the endpoint policy, and |
| | | 116 | | /// which answers "no limit" for every endpoint without that policy. Both apply to every |
| | | 117 | | /// administrator's request, so neither can be stepped around by changing only the other |
| | | 118 | | /// (Threat Model TH-03). |
| | | 119 | | /// </para> |
| | | 120 | | /// <para> |
| | | 121 | | /// Thirty a minute is more than anyone creates by hand. The limiter runs after |
| | | 122 | | /// authentication, so the actor is known. The global address bucket is spent only by an |
| | | 123 | | /// administrator's person access token (<see cref="CountsAgainstCreationAddress" />): a |
| | | 124 | | /// request without a token, or with a User's, a challenge or a subject-rights token, is |
| | | 125 | | /// refused by the endpoint anyway and must not use up a bound administrators share. |
| | | 126 | | /// </para> |
| | | 127 | | /// </remarks> |
| | | 128 | | private static void AddAccountCreationRateLimiting(RateLimiterOptions options, TrustedCallerOptions callers) |
| | | 129 | | { |
| | 82 | 130 | | FixedWindowRateLimiterOptions Window(string _) => new() |
| | 82 | 131 | | { |
| | 82 | 132 | | PermitLimit = AccountCreationPermitLimit, |
| | 82 | 133 | | Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds), |
| | 82 | 134 | | QueueLimit = 0 |
| | 82 | 135 | | }; |
| | | 136 | | |
| | 966 | 137 | | options.AddPolicy(AccountCreationRateLimitPolicy, httpContext => |
| | 966 | 138 | | { |
| | 197 | 139 | | var actor = httpContext.GetUser<IdentityUser>(); |
| | 966 | 140 | | |
| | 197 | 141 | | return RateLimitPartition.GetFixedWindowLimiter( |
| | 197 | 142 | | partitionKey: actor is null |
| | 197 | 143 | | ? AuthRateLimitPartition.KeyFor(httpContext, callers) |
| | 197 | 144 | | : $"actor:{actor.Id}", |
| | 197 | 145 | | factory: Window); |
| | 966 | 146 | | }); |
| | | 147 | | |
| | 966 | 148 | | options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext => |
| | 2475 | 149 | | httpContext.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == |
| | 2475 | 150 | | AccountCreationRateLimitPolicy && CountsAgainstCreationAddress(httpContext.GetUser<IdentityUser>()) |
| | 2475 | 151 | | ? RateLimitPartition.GetFixedWindowLimiter( |
| | 2475 | 152 | | "creation:" + AuthRateLimitPartition.KeyFor(httpContext, callers), Window) |
| | 2475 | 153 | | : RateLimitPartition.GetNoLimiter(string.Empty)); |
| | 966 | 154 | | } |
| | | 155 | | |
| | | 156 | | /// <summary> |
| | | 157 | | /// Throttles the credential-checking endpoints (login, password recovery/reset, email |
| | | 158 | | /// verification, Google sign-in, 2FA challenge verification and resend) per calling IP |
| | | 159 | | /// address. None of these require a bearer token, so nothing else stops a caller from firing |
| | | 160 | | /// an unbounded burst of requests at them — each login attempt alone costs a full Argon2id |
| | | 161 | | /// verification (600 MB / 16 threads by this codebase's hashing library default), and a 2FA |
| | | 162 | | /// email code has only 1,000,000 possible values, so an unthrottled brute force or memory/CPU |
| | | 163 | | /// exhaustion attempt is realistic without this. The authenticated endpoints that check a |
| | | 164 | | /// password or a second factor (2FA confirm, disable and recovery-code regeneration, and the |
| | | 165 | | /// erasure request) carry it too: a bearer token is not the password, and without a limit |
| | | 166 | | /// whoever holds one could test passwords or six-digit codes against them as fast as they |
| | | 167 | | /// could send requests, outside the login lockout. The verification-link resend carries it |
| | | 168 | | /// as well, since each request there mails the account's address. Policy name matches the |
| | | 169 | | /// <see cref="Microsoft.AspNetCore.RateLimiting.EnableRateLimitingAttribute" /> applied to |
| | | 170 | | /// each endpoint in <c>AuthController</c>. |
| | | 171 | | /// </summary> |
| | | 172 | | /// <remarks> |
| | | 173 | | /// Partitioned by <see cref="HttpContext.Connection" />'s remote IP (an IPv6 caller by its /56 — |
| | | 174 | | /// see <see cref="RateLimitPartitionKey" />), which is the caller's own |
| | | 175 | | /// only when <see cref="TrustedProxyOptions" /> names the proxy in front of the API; otherwise |
| | | 176 | | /// every caller shares the proxy's partition. A client system that is itself one caller to |
| | | 177 | | /// Heimdall for many end users may instead name each user's address, with a key from |
| | | 178 | | /// <see cref="TrustedCallerOptions" /> (see <see cref="AuthRateLimitPartition" />). Either way |
| | | 179 | | /// this is a per-instance, defense-in-depth throttle, not a substitute for a WAF or an API |
| | | 180 | | /// gateway's own rate limiting in front of a real deployment. |
| | | 181 | | /// </remarks> |
| | | 182 | | public static IServiceCollection AddAuthEndpointRateLimiting(this IServiceCollection services) |
| | | 183 | | { |
| | | 184 | | // Throws on a key too short to be a secret, so a misconfigured deployment stops here. |
| | 966 | 185 | | var callers = TrustedCallerOptions.FromEnvironment(); |
| | | 186 | | |
| | 966 | 187 | | services.AddSingleton(callers); |
| | | 188 | | |
| | | 189 | | // How many, never which: the keys are secrets and the headers carrying them are not logged. |
| | 966 | 190 | | Log.Information( |
| | 966 | 191 | | "{Count} trusted caller key(s) configured ({Variable})", |
| | 966 | 192 | | callers.Count, TrustedCallerOptions.KeysVariable); |
| | | 193 | | |
| | 966 | 194 | | services.AddRateLimiter(options => |
| | 966 | 195 | | { |
| | 966 | 196 | | options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; |
| | 966 | 197 | | |
| | 966 | 198 | | // TH-32: a turned-away caller is told when to come back and gets the API's usual error |
| | 966 | 199 | | // envelope, not an empty 429. The lease's own hint is used when the limiter gives one; |
| | 966 | 200 | | // a fixed window gives none, so the window's length is the honest upper bound. |
| | 966 | 201 | | options.OnRejected = async (context, cancellationToken) => |
| | 966 | 202 | | { |
| | 16 | 203 | | var retryAfterSeconds = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter) |
| | 16 | 204 | | ? (int)Math.Ceiling(retryAfter.TotalSeconds) |
| | 16 | 205 | | : AuthEndpointRateLimitWindowSeconds; |
| | 966 | 206 | | |
| | 16 | 207 | | context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; |
| | 16 | 208 | | context.HttpContext.Response.Headers.RetryAfter = |
| | 16 | 209 | | Math.Max(1, retryAfterSeconds).ToString(CultureInfo.InvariantCulture); |
| | 966 | 210 | | |
| | 16 | 211 | | await context.HttpContext.Response.WriteAsJsonAsync( |
| | 16 | 212 | | DataOutput<object?>.New.WithError(RateLimitMessages.TooManyRequests), |
| | 16 | 213 | | cancellationToken); |
| | 982 | 214 | | }; |
| | 966 | 215 | | |
| | 966 | 216 | | options.AddPolicy(AuthEndpointRateLimitPolicy, httpContext => |
| | 1658 | 217 | | RateLimitPartition.GetFixedWindowLimiter( |
| | 1658 | 218 | | partitionKey: AuthRateLimitPartition.KeyFor(httpContext, callers), |
| | 2004 | 219 | | factory: _ => new FixedWindowRateLimiterOptions |
| | 2004 | 220 | | { |
| | 2004 | 221 | | PermitLimit = 10, |
| | 2004 | 222 | | Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds), |
| | 2004 | 223 | | QueueLimit = 0 |
| | 2004 | 224 | | })); |
| | 966 | 225 | | |
| | 966 | 226 | | AddAccountCreationRateLimiting(options, callers); |
| | 1932 | 227 | | }); |
| | | 228 | | |
| | 966 | 229 | | return services; |
| | | 230 | | } |
| | | 231 | | } |