fix(transport): enforce the documented per-IP bundle-unlock throttle (row 44)
BundleUnlockRateLimiter was registered (ServiceCollectionExtensions), injected into BundleImporter, unit-tested, documented on TransportOptions.MaxUnlockAttemptsPerIpPerHour = 10 and pinned in appsettings.Central.json — but _unlockRateLimiter was never invoked. A brute-force passphrase attacker against a stolen bundle was bounded only by the per-bundle 3-strike lockout, which a re-export side-steps (new ContentHash). LoadAsync now registers every unlock attempt against the limiter. Ordering is load-bearing: AFTER the per-bundle lockout check (hammering a locked bundle spends no hourly budget) and BEFORE the decrypt (so a denial burns no per-bundle strike — otherwise the throttle would be a way to lock a bundle someone is legitimately importing). Over budget throws the new BundleUnlockThrottledException, fail-closed with a clear message on both surfaces; it lives in Commons because the management surface (Commons-only references) must catch it to return a curated error rather than MapFault's generic internal-error text. Client key plumbing: - Central UI: new scoped CircuitClientAddressProvider snapshots Connection.RemoteIpAddress at circuit establishment — the same constraint and the same pattern as CookieAuthenticationStateProvider, since HttpContext is null by the time a circuit submits a passphrase. - CLI/management: additive ManagementEnvelope.ClientIp, stamped by ManagementEndpoints and threaded through DispatchCommand to the preview/import bundle handlers. - No attributable address => a single SHARED "<unattributed>" bucket, not a bypass: unknown clients get a stricter budget, never none. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries (Component-Security.md). Docs: Component-Transport.md + docs/components/Transport.md record the two-level ordering, the client-key sourcing, the shared-bucket fallback and a troubleshooting entry for the new error. Tests: 6 importer tests (cap enforced; denial != passphrase failure, no strike burned; per-client isolation; shared unattributed bucket; unencrypted bundles consume no budget; a locked bundle spends no hourly budget) — 4 of the 6 fail with the limiter call disabled, i.e. against the dead-code state. Plus ManagementActor tests pinning the ClientIp forwarding and the curated throttle error.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the remote IP of the client that established this Blazor circuit.
|
||||
/// <para>
|
||||
/// Same constraint — and the same solution — as
|
||||
/// <see cref="CookieAuthenticationStateProvider"/>: <see cref="IHttpContextAccessor.HttpContext"/>
|
||||
/// is valid only during the initial HTTP request that establishes the circuit and
|
||||
/// is null (or stale/foreign) for the life of the SignalR connection afterwards.
|
||||
/// Registered <c>Scoped</c>, this type is constructed inside that request's DI
|
||||
/// scope, so it snapshots <c>Connection.RemoteIpAddress</c> <b>once</b> in the
|
||||
/// constructor and serves that snapshot for the circuit's lifetime.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The value is only as trustworthy as the proxy configuration: it is whatever
|
||||
/// Kestrel reports as the connection's remote address, which <c>ForwardedHeaders</c>
|
||||
/// (see <c>ForwardedHeadersSetup</c>, enabled for the shipped Traefik topologies)
|
||||
/// rewrites from <c>X-Forwarded-For</c> for CONFIGURED proxies only. Behind an
|
||||
/// untrusted proxy every circuit collapses onto the proxy's address — consumers
|
||||
/// must treat that as a shared bucket, not as per-client isolation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CircuitClientAddressProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Snapshots the client address from the current HTTP context for use throughout
|
||||
/// the circuit lifetime.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">Accessor used to read the circuit-establishing request.</param>
|
||||
public CircuitClientAddressProvider(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContextAccessor);
|
||||
ClientAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: constructs the provider with an explicit address instead of reading
|
||||
/// an <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <param name="clientAddress">The address to serve, or null for "unknown".</param>
|
||||
public CircuitClientAddressProvider(string? clientAddress)
|
||||
{
|
||||
ClientAddress = clientAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The remote IP of the circuit-establishing request, or null when it could not be
|
||||
/// determined (no HTTP context, or a transport that reports no remote address).
|
||||
/// </summary>
|
||||
public string? ClientAddress { get; }
|
||||
}
|
||||
+33
-1
@@ -86,6 +86,11 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
[Inject] private IAuditService AuditService { get; set; } = default!;
|
||||
[Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;
|
||||
|
||||
// Register row 44: the per-IP-per-hour unlock throttle needs the caller's
|
||||
// address, and HttpContext is long gone by the time a circuit submits a
|
||||
// passphrase — this provider snapshots it when the circuit was established.
|
||||
[Inject] private CircuitClientAddressProvider ClientAddress { get; set; } = default!;
|
||||
|
||||
// The Map step needs the destination environment's sites + each
|
||||
// site's connections to populate the "map to existing target" dropdowns.
|
||||
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
|
||||
@@ -236,7 +241,8 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
using var stream = new FileStream(
|
||||
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
_session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
|
||||
_session = await BundleImporter.LoadAsync(
|
||||
stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
|
||||
_errorMessage = null;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
@@ -253,6 +259,14 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
_session = null;
|
||||
throw;
|
||||
}
|
||||
catch (BundleUnlockThrottledException)
|
||||
{
|
||||
// Register row 44: per-IP-per-hour budget spent. Bubble so the caller
|
||||
// can surface it WITHOUT counting a passphrase failure — the attempt
|
||||
// never reached the decryptor.
|
||||
_session = null;
|
||||
throw;
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
_session = null;
|
||||
@@ -330,6 +344,11 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
/// shared across tabs / CLI / circuits). The Razor counter is kept ONLY for
|
||||
/// display ("3 of N attempts used") — it is no longer the source of truth.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Register row 44: a <see cref="BundleUnlockThrottledException"/> is a third,
|
||||
/// distinct outcome — this CLIENT has spent its per-IP-per-hour unlock budget.
|
||||
/// It burns no per-bundle strike and leaves the wizard on Step 2.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task SubmitPassphraseAsync()
|
||||
{
|
||||
@@ -360,6 +379,19 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
ResetSessionState();
|
||||
_step = ImportWizardStep.Upload;
|
||||
}
|
||||
catch (BundleUnlockThrottledException ex)
|
||||
{
|
||||
// Register row 44: fail closed on the per-IP-per-hour cap. Deliberately
|
||||
// does NOT touch _failedUnlockAttempts — a throttled attempt never
|
||||
// reached the decryptor, so it must not consume one of the bundle's
|
||||
// three strikes (otherwise the throttle could be used to lock a bundle
|
||||
// an operator is legitimately importing). The wizard stays on Step 2 so
|
||||
// the operator can retry once the hour window rolls forward.
|
||||
_passphrase = string.Empty;
|
||||
var throttledEntity = _session?.Manifest.ContentHash ?? "<no-session>";
|
||||
await EmitUnlockFailedAuditRowAsync(throttledEntity, _failedUnlockAttempts, ex.Message);
|
||||
_errorMessage = ex.Message;
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
_failedUnlockAttempts++;
|
||||
|
||||
@@ -27,6 +27,13 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
||||
services.AddCascadingAuthenticationState();
|
||||
|
||||
// Snapshots the circuit-establishing request's remote IP (HttpContext is only
|
||||
// valid then), so circuit-lifetime code can still attribute a client address —
|
||||
// today the Transport import wizard's per-IP unlock throttle. Explicit factory
|
||||
// because the type carries a second (test-seam) string ctor.
|
||||
services.AddScoped(sp => new CircuitClientAddressProvider(
|
||||
sp.GetRequiredService<Microsoft.AspNetCore.Http.IHttpContextAccessor>()));
|
||||
|
||||
// Resolves the current user's permitted site set from their SiteId claims
|
||||
// so Deployment/Monitoring pages can enforce site scoping.
|
||||
services.AddScoped<SiteScopeService>();
|
||||
|
||||
@@ -10,8 +10,20 @@ public interface IBundleImporter
|
||||
/// <param name="bundleStream">Stream containing the bundle zip archive.</param>
|
||||
/// <param name="passphrase">Optional passphrase for decrypting an encrypted bundle.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="clientKey">
|
||||
/// Identity of the calling client — the remote IP — used to enforce the
|
||||
/// per-IP-per-hour unlock throttle (<c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>)
|
||||
/// on encrypted bundles. Trailing optional so unencrypted / in-process callers
|
||||
/// need not supply one; null or blank counts the attempt against a single shared
|
||||
/// "unattributed caller" bucket rather than skipping the throttle, so a caller
|
||||
/// that cannot be attributed can never spend an unbounded budget.
|
||||
/// </param>
|
||||
/// <returns>A task that resolves to session metadata for the loaded bundle.</returns>
|
||||
Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
|
||||
Task<BundleSession> LoadAsync(
|
||||
Stream bundleStream,
|
||||
string? passphrase,
|
||||
CancellationToken ct = default,
|
||||
string? clientKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Diffs the loaded bundle against the target database and returns a per-artifact preview.
|
||||
|
||||
@@ -4,7 +4,22 @@ public record AuthenticatedUser(
|
||||
string Username, string DisplayName,
|
||||
string[] Roles, string[] PermittedSiteIds);
|
||||
|
||||
public record ManagementEnvelope(AuthenticatedUser User, object Command, string CorrelationId);
|
||||
/// <param name="User">The authenticated caller.</param>
|
||||
/// <param name="Command">The management command to run.</param>
|
||||
/// <param name="CorrelationId">Per-request correlation id echoed on the reply.</param>
|
||||
/// <param name="ClientIp">
|
||||
/// Remote IP of the HTTP caller, when the envelope came in over the management
|
||||
/// endpoint. Additive trailing-optional (message contracts evolve additively), so
|
||||
/// in-cluster senders that construct an envelope directly stay source-compatible and
|
||||
/// simply carry null. Consumed by the Transport bundle handlers to enforce the
|
||||
/// per-IP-per-hour unlock throttle; null degrades to a shared throttle bucket rather
|
||||
/// than to no throttling (register row 44).
|
||||
/// </param>
|
||||
public record ManagementEnvelope(
|
||||
AuthenticatedUser User,
|
||||
object Command,
|
||||
string CorrelationId,
|
||||
string? ClientIp = null);
|
||||
|
||||
public record ManagementSuccess(string CorrelationId, string JsonData);
|
||||
public record ManagementError(string CorrelationId, string Error, string ErrorCode);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// T-004 / register row 44: thrown by <c>IBundleImporter.LoadAsync</c> when a client has
|
||||
/// spent its trailing-hour bundle-unlock budget (<c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
|
||||
/// default 10) as measured by <c>BundleUnlockRateLimiter</c>.
|
||||
/// <para>
|
||||
/// Distinct from <c>BundleLockedException</c> on purpose: that one means "this BUNDLE has
|
||||
/// burned its 3 strikes" and is keyed by the bundle's content hash; this one means "this
|
||||
/// CLIENT has attempted too many unlocks, across any bundles" and is keyed by the caller's
|
||||
/// IP. A throttled attempt never reaches the decryptor, so it deliberately burns no
|
||||
/// per-bundle strike — otherwise the throttle could be used to lock out a bundle an
|
||||
/// operator is legitimately importing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lives in Commons rather than beside the importer because it is part of the
|
||||
/// <c>IBundleImporter.LoadAsync</c> contract, which is declared here: the management
|
||||
/// surface (which references Commons but not Transport) must be able to catch it to
|
||||
/// return a clear, curated error instead of a generic internal-failure message.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleUnlockThrottledException : Exception
|
||||
{
|
||||
/// <summary>The rate-limiter key the attempt was counted against — the client IP, or the shared unattributed-caller bucket.</summary>
|
||||
public string ClientKey { get; }
|
||||
|
||||
/// <summary>The configured trailing-window cap that was reached.</summary>
|
||||
public int MaxAttemptsPerWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleUnlockThrottledException"/>.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">The rate-limiter key (client IP, or the shared unattributed bucket).</param>
|
||||
/// <param name="maxAttemptsPerWindow">The configured per-window cap that was reached.</param>
|
||||
public BundleUnlockThrottledException(string clientKey, int maxAttemptsPerWindow)
|
||||
: base(
|
||||
$"Too many bundle unlock attempts ({maxAttemptsPerWindow} per hour) from this client. "
|
||||
+ "Wait for the hour window to roll forward before trying again.")
|
||||
{
|
||||
ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey));
|
||||
MaxAttemptsPerWindow = maxAttemptsPerWindow;
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ public class ManagementActor : ReceiveActor
|
||||
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
|
||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
|
||||
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
|
||||
}
|
||||
|
||||
@@ -294,7 +294,8 @@ public class ManagementActor : ReceiveActor
|
||||
_ => null
|
||||
};
|
||||
|
||||
private async Task<object?> DispatchCommand(IServiceProvider sp, object command, AuthenticatedUser user)
|
||||
private async Task<object?> DispatchCommand(
|
||||
IServiceProvider sp, object command, AuthenticatedUser user, string? clientIp = null)
|
||||
{
|
||||
return command switch
|
||||
{
|
||||
@@ -474,8 +475,8 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
// Transport bundle operations
|
||||
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
|
||||
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd),
|
||||
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username),
|
||||
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd, clientIp),
|
||||
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username, clientIp),
|
||||
|
||||
// NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step
|
||||
// "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP
|
||||
@@ -3549,7 +3550,7 @@ public class ManagementActor : ReceiveActor
|
||||
/// not apply anything; callers can use this to gate Apply on the diff shape.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandlePreviewBundle(
|
||||
IServiceProvider sp, PreviewBundleCommand cmd)
|
||||
IServiceProvider sp, PreviewBundleCommand cmd, string? clientIp)
|
||||
{
|
||||
await TransportGate.WaitAsync();
|
||||
try
|
||||
@@ -3560,7 +3561,7 @@ public class ManagementActor : ReceiveActor
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(bytes);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -3570,6 +3571,13 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||||
}
|
||||
catch (BundleUnlockThrottledException ex)
|
||||
{
|
||||
// Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
|
||||
// prints the reason instead of MapFault's generic internal-error
|
||||
// message; no per-bundle strike was burned.
|
||||
throw new ManagementCommandException(ex.Message);
|
||||
}
|
||||
|
||||
// Register row 43: a preview NEVER applies, so nothing else would ever
|
||||
// release this session — every `transport preview` used to burn one of
|
||||
@@ -3605,7 +3613,7 @@ public class ManagementActor : ReceiveActor
|
||||
/// <see cref="ConflictKind.Blocker"/> rows fail the call before Apply.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleImportBundle(
|
||||
IServiceProvider sp, ImportBundleCommand cmd, string username)
|
||||
IServiceProvider sp, ImportBundleCommand cmd, string username, string? clientIp)
|
||||
{
|
||||
await TransportGate.WaitAsync();
|
||||
try
|
||||
@@ -3618,7 +3626,7 @@ public class ManagementActor : ReceiveActor
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(bytes);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -3628,6 +3636,13 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||||
}
|
||||
catch (BundleUnlockThrottledException ex)
|
||||
{
|
||||
// Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
|
||||
// prints the reason instead of MapFault's generic internal-error
|
||||
// message; no per-bundle strike was burned.
|
||||
throw new ManagementCommandException(ex.Message);
|
||||
}
|
||||
|
||||
// Register row 43: ApplyAsync releases the session on both its success
|
||||
// and failure paths, but every abort BEFORE it (blockers, unresolved
|
||||
|
||||
@@ -134,7 +134,14 @@ public static class ManagementEndpoints
|
||||
}
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var envelope = new ManagementEnvelope(authenticatedUser, command, correlationId);
|
||||
// Carry the caller's remote IP so the Transport bundle handlers can enforce
|
||||
// the per-IP-per-hour unlock throttle (register row 44). ForwardedHeaders has
|
||||
// already rewritten this from X-Forwarded-For for CONFIGURED proxies only
|
||||
// (Program.cs), so it is a real client address in the shipped topologies and
|
||||
// the proxy's address behind an untrusted one.
|
||||
var envelope = new ManagementEnvelope(
|
||||
authenticatedUser, command, correlationId,
|
||||
context.Connection.RemoteIpAddress?.ToString());
|
||||
|
||||
var askTimeout = ResolveAskTimeout(
|
||||
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value,
|
||||
|
||||
@@ -165,8 +165,22 @@ public sealed class BundleImporter : IBundleImporter
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-004 / register row 44: the rate-limiter bucket used when a caller supplies no
|
||||
/// client key. Deliberately a single SHARED key rather than a per-call bypass — an
|
||||
/// unattributable caller is throttled together with every other unattributable
|
||||
/// caller, so "no IP available" degrades to a stricter budget, never to none. A
|
||||
/// production unlock always carries an IP (Central UI circuit, management endpoint),
|
||||
/// so in practice this bucket only serves in-process callers and tests.
|
||||
/// </summary>
|
||||
internal const string UnattributedClientKey = "<unattributed>";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
|
||||
public async Task<BundleSession> LoadAsync(
|
||||
Stream bundleStream,
|
||||
string? passphrase,
|
||||
CancellationToken ct = default,
|
||||
string? clientKey = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleStream);
|
||||
|
||||
@@ -264,6 +278,33 @@ public sealed class BundleImporter : IBundleImporter
|
||||
throw new BundleLockedException(manifest.ContentHash, priorFailures);
|
||||
}
|
||||
|
||||
// T-004 / register row 44: the documented per-IP-per-hour cap. The limiter
|
||||
// was registered, injected and unit-tested but never invoked, so a
|
||||
// brute-force attacker against a stolen bundle was bounded only by the
|
||||
// per-bundle 3-strike lockout (side-steppable by re-exporting the bundle
|
||||
// for a fresh ContentHash).
|
||||
//
|
||||
// Ordering is load-bearing:
|
||||
// * AFTER the per-bundle lockout check, so hammering an already-locked
|
||||
// bundle does not spend the client's hourly budget on rejected calls.
|
||||
// * BEFORE the decrypt, so a THROTTLED attempt never reaches
|
||||
// IncrementUnlockFailureCount — a denial must not burn a per-bundle
|
||||
// strike, or the throttle would become a way to lock a bundle that an
|
||||
// operator is legitimately importing.
|
||||
// Registered on every unlock attempt (successful ones included) because the
|
||||
// documented budget counts attempts, not failures.
|
||||
var throttleKey = string.IsNullOrWhiteSpace(clientKey)
|
||||
? UnattributedClientKey
|
||||
: clientKey;
|
||||
var perIpCap = _options.Value.MaxUnlockAttemptsPerIpPerHour;
|
||||
if (!_unlockRateLimiter.TryRegisterAttempt(throttleKey, perIpCap))
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Bundle unlock throttled: client {ClientKey} exceeded {Cap} attempts in the trailing hour.",
|
||||
throttleKey, perIpCap);
|
||||
throw new BundleUnlockThrottledException(throttleKey, perIpCap);
|
||||
}
|
||||
|
||||
var aad = Encryption.BundleManifestAad.Compute(manifest);
|
||||
try
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user