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