5ab26a4233
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.
27 lines
1.2 KiB
C#
27 lines
1.2 KiB
C#
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
|
|
public record AuthenticatedUser(
|
|
string Username, string DisplayName,
|
|
string[] Roles, string[] PermittedSiteIds);
|
|
|
|
/// <param name="User">The authenticated caller.</param>
|
|
/// <param name="Command">The management command to run.</param>
|
|
/// <param name="CorrelationId">Per-request correlation id echoed on the reply.</param>
|
|
/// <param name="ClientIp">
|
|
/// Remote IP of the HTTP caller, when the envelope came in over the management
|
|
/// endpoint. Additive trailing-optional (message contracts evolve additively), so
|
|
/// in-cluster senders that construct an envelope directly stay source-compatible and
|
|
/// simply carry null. Consumed by the Transport bundle handlers to enforce the
|
|
/// per-IP-per-hour unlock throttle; null degrades to a shared throttle bucket rather
|
|
/// than to no throttling (register row 44).
|
|
/// </param>
|
|
public record ManagementEnvelope(
|
|
AuthenticatedUser User,
|
|
object Command,
|
|
string CorrelationId,
|
|
string? ClientIp = null);
|
|
|
|
public record ManagementSuccess(string CorrelationId, string JsonData);
|
|
public record ManagementError(string CorrelationId, string Error, string ErrorCode);
|
|
public record ManagementUnauthorized(string CorrelationId, string Message);
|