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:
Joseph Doherty
2026-08-15 06:01:14 -04:00
parent 708389829b
commit 5ab26a4233
16 changed files with 545 additions and 24 deletions
@@ -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;
}
}