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
@@ -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; }
}