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:
@@ -2734,7 +2734,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -2787,7 +2787,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -2865,7 +2865,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -3089,7 +3089,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -3126,6 +3126,99 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Contains("OpcA", success.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewBundleCommand_ReleasesTheSession_AndForwardsTheCallersIp()
|
||||
{
|
||||
// Register row 43: a preview never applies, so nothing else releases the
|
||||
// session — it used to hold one of MaxConcurrentImportSessions for the full
|
||||
// TTL. Register row 44: the envelope's client IP must reach LoadAsync so the
|
||||
// per-IP-per-hour unlock throttle can attribute the attempt.
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Manifest = null!,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
});
|
||||
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportPreview(
|
||||
sessionId,
|
||||
Array.Empty<Commons.Types.Transport.ImportPreviewItem>()));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new PreviewBundleCommand(payload, null), "Administrator")
|
||||
with { ClientIp = "198.51.100.9" };
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
importer.Received(1).LoadAsync(
|
||||
Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), "198.51.100.9");
|
||||
importer.Received(1).Abandon(sessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportBundleCommand_WithBlockerRow_StillReleasesTheSession()
|
||||
{
|
||||
// Register row 43: ApplyAsync releases the session, but this import aborts
|
||||
// BEFORE apply — the handler must release it anyway.
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Manifest = null!,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
});
|
||||
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportPreview(
|
||||
sessionId,
|
||||
new[]
|
||||
{
|
||||
new Commons.Types.Transport.ImportPreviewItem(
|
||||
EntityType: "Template",
|
||||
Name: "BlockedTemplate",
|
||||
ExistingVersion: null,
|
||||
IncomingVersion: 1,
|
||||
Kind: Commons.Types.Transport.ConflictKind.Blocker,
|
||||
FieldDiffJson: null,
|
||||
BlockerReason: "FK to missing site"),
|
||||
}));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"));
|
||||
|
||||
ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
importer.Received(1).Abandon(sessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportBundleCommand_ThrottledUnlock_SurfacesTheCuratedReason()
|
||||
{
|
||||
// Register row 44: a per-IP throttle denial must fail closed with a clear
|
||||
// message, not MapFault's generic "an internal error occurred".
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns<Commons.Types.Transport.BundleSession>(_ =>
|
||||
throw new Commons.Types.Transport.BundleUnlockThrottledException("198.51.100.9", 10));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ImportBundleCommand(payload, "pass", "skip"), "Administrator"));
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Too many bundle unlock attempts", response.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Native alarm source CRUD (Task 21)
|
||||
// ========================================================================
|
||||
|
||||
@@ -53,7 +53,7 @@ public class TransportGateTests : TestKit, IDisposable
|
||||
var loadCalls = 0;
|
||||
|
||||
var session = new BundleSession { SessionId = Guid.NewGuid() };
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref loadCalls);
|
||||
|
||||
Reference in New Issue
Block a user