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:
@@ -130,7 +130,7 @@ public class ManagementActor : ReceiveActor
|
||||
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
|
||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
|
||||
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
|
||||
}
|
||||
|
||||
@@ -294,7 +294,8 @@ public class ManagementActor : ReceiveActor
|
||||
_ => null
|
||||
};
|
||||
|
||||
private async Task<object?> DispatchCommand(IServiceProvider sp, object command, AuthenticatedUser user)
|
||||
private async Task<object?> DispatchCommand(
|
||||
IServiceProvider sp, object command, AuthenticatedUser user, string? clientIp = null)
|
||||
{
|
||||
return command switch
|
||||
{
|
||||
@@ -474,8 +475,8 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
// Transport bundle operations
|
||||
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
|
||||
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd),
|
||||
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username),
|
||||
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd, clientIp),
|
||||
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username, clientIp),
|
||||
|
||||
// NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step
|
||||
// "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP
|
||||
@@ -3549,7 +3550,7 @@ public class ManagementActor : ReceiveActor
|
||||
/// not apply anything; callers can use this to gate Apply on the diff shape.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandlePreviewBundle(
|
||||
IServiceProvider sp, PreviewBundleCommand cmd)
|
||||
IServiceProvider sp, PreviewBundleCommand cmd, string? clientIp)
|
||||
{
|
||||
await TransportGate.WaitAsync();
|
||||
try
|
||||
@@ -3560,7 +3561,7 @@ public class ManagementActor : ReceiveActor
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(bytes);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -3570,6 +3571,13 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||||
}
|
||||
catch (BundleUnlockThrottledException ex)
|
||||
{
|
||||
// Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
|
||||
// prints the reason instead of MapFault's generic internal-error
|
||||
// message; no per-bundle strike was burned.
|
||||
throw new ManagementCommandException(ex.Message);
|
||||
}
|
||||
|
||||
// Register row 43: a preview NEVER applies, so nothing else would ever
|
||||
// release this session — every `transport preview` used to burn one of
|
||||
@@ -3605,7 +3613,7 @@ public class ManagementActor : ReceiveActor
|
||||
/// <see cref="ConflictKind.Blocker"/> rows fail the call before Apply.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleImportBundle(
|
||||
IServiceProvider sp, ImportBundleCommand cmd, string username)
|
||||
IServiceProvider sp, ImportBundleCommand cmd, string username, string? clientIp)
|
||||
{
|
||||
await TransportGate.WaitAsync();
|
||||
try
|
||||
@@ -3618,7 +3626,7 @@ public class ManagementActor : ReceiveActor
|
||||
try
|
||||
{
|
||||
await using var stream = new MemoryStream(bytes);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase);
|
||||
session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -3628,6 +3636,13 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||||
}
|
||||
catch (BundleUnlockThrottledException ex)
|
||||
{
|
||||
// Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
|
||||
// prints the reason instead of MapFault's generic internal-error
|
||||
// message; no per-bundle strike was burned.
|
||||
throw new ManagementCommandException(ex.Message);
|
||||
}
|
||||
|
||||
// Register row 43: ApplyAsync releases the session on both its success
|
||||
// and failure paths, but every abort BEFORE it (blockers, unresolved
|
||||
|
||||
Reference in New Issue
Block a user