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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user