< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.RateLimitingConfiguration
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Registration/RateLimitingConfiguration.cs
Line coverage
100%
Covered lines: 74
Uncovered lines: 0
Coverable lines: 74
Total lines: 231
Line coverage: 100%
Branch coverage
92%
Covered branches: 24
Total branches: 26
Branch coverage: 92.3%
Method coverage
100%
Covered methods: 5
Fully covered methods: 5
Total methods: 5
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
RateLimitPartitionKey(...)100%66100%
CountsAgainstCreationAddress(...)100%88100%
Window()100%11100%
AddAccountCreationRateLimiting(...)90%1010100%
AddAuthEndpointRateLimiting(...)50%22100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Registration/RateLimitingConfiguration.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Net.Sockets;
 4using System.Threading.RateLimiting;
 5using ArturRios.Data.Relational.Core.DependencyInjection;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Heimdall.WebApi.Security;
 9using ArturRios.Output;
 10using ArturRios.Util.WebApi.Configuration;
 11using ArturRios.Util.WebApi.Security.Extensions;
 12using Microsoft.AspNetCore.RateLimiting;
 13using Serilog;
 14
 15namespace ArturRios.Heimdall.WebApi;
 16
 17public 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    {
 83370        if (address is null)
 71        {
 67072            return "unknown";
 73        }
 74
 16375        if (address.IsIPv4MappedToIPv6)
 76        {
 277            address = address.MapToIPv4();
 78        }
 79
 16380        if (address.AddressFamily != AddressFamily.InterNetworkV6)
 81        {
 13982            return address.ToString();
 83        }
 84
 2485        var bytes = address.GetAddressBytes();
 2486        Array.Clear(bytes, RateLimitIPv6PrefixLength / 8, 16 - RateLimitIPv6PrefixLength / 8);
 87
 2488        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) =>
 19999        actor is { MfaPending: false, SubjectRightsOnly: false } &&
 199100        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    {
 82130        FixedWindowRateLimiterOptions Window(string _) => new()
 82131        {
 82132            PermitLimit = AccountCreationPermitLimit,
 82133            Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds),
 82134            QueueLimit = 0
 82135        };
 136
 966137        options.AddPolicy(AccountCreationRateLimitPolicy, httpContext =>
 966138        {
 197139            var actor = httpContext.GetUser<IdentityUser>();
 966140
 197141            return RateLimitPartition.GetFixedWindowLimiter(
 197142                partitionKey: actor is null
 197143                    ? AuthRateLimitPartition.KeyFor(httpContext, callers)
 197144                    : $"actor:{actor.Id}",
 197145                factory: Window);
 966146        });
 147
 966148        options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
 2475149            httpContext.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName ==
 2475150            AccountCreationRateLimitPolicy && CountsAgainstCreationAddress(httpContext.GetUser<IdentityUser>())
 2475151                ? RateLimitPartition.GetFixedWindowLimiter(
 2475152                    "creation:" + AuthRateLimitPartition.KeyFor(httpContext, callers), Window)
 2475153                : RateLimitPartition.GetNoLimiter(string.Empty));
 966154    }
 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.
 966185        var callers = TrustedCallerOptions.FromEnvironment();
 186
 966187        services.AddSingleton(callers);
 188
 189        // How many, never which: the keys are secrets and the headers carrying them are not logged.
 966190        Log.Information(
 966191            "{Count} trusted caller key(s) configured ({Variable})",
 966192            callers.Count, TrustedCallerOptions.KeysVariable);
 193
 966194        services.AddRateLimiter(options =>
 966195        {
 966196            options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
 966197
 966198            // TH-32: a turned-away caller is told when to come back and gets the API's usual error
 966199            // envelope, not an empty 429. The lease's own hint is used when the limiter gives one;
 966200            // a fixed window gives none, so the window's length is the honest upper bound.
 966201            options.OnRejected = async (context, cancellationToken) =>
 966202            {
 16203                var retryAfterSeconds = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
 16204                    ? (int)Math.Ceiling(retryAfter.TotalSeconds)
 16205                    : AuthEndpointRateLimitWindowSeconds;
 966206
 16207                context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
 16208                context.HttpContext.Response.Headers.RetryAfter =
 16209                    Math.Max(1, retryAfterSeconds).ToString(CultureInfo.InvariantCulture);
 966210
 16211                await context.HttpContext.Response.WriteAsJsonAsync(
 16212                    DataOutput<object?>.New.WithError(RateLimitMessages.TooManyRequests),
 16213                    cancellationToken);
 982214            };
 966215
 966216            options.AddPolicy(AuthEndpointRateLimitPolicy, httpContext =>
 1658217                RateLimitPartition.GetFixedWindowLimiter(
 1658218                    partitionKey: AuthRateLimitPartition.KeyFor(httpContext, callers),
 2004219                    factory: _ => new FixedWindowRateLimiterOptions
 2004220                    {
 2004221                        PermitLimit = 10,
 2004222                        Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds),
 2004223                        QueueLimit = 0
 2004224                    }));
 966225
 966226            AddAccountCreationRateLimiting(options, callers);
 1932227        });
 228
 966229        return services;
 230    }
 231}