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