Merge branch 'transport-session-fixes' — abandoned-session release + per-IP unlock throttle enforced (register rows 43, 44)
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; }
|
||||
}
|
||||
+88
-6
@@ -51,6 +51,21 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
|
||||
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
|
||||
/// successful Apply / component Dispose, so an abandoned wizard does not leak
|
||||
/// staged bundle plaintext beyond circuit teardown.
|
||||
///
|
||||
/// <para>
|
||||
/// Server-side session release (register row 43): the staged temp file is only
|
||||
/// half of an abandoned wizard's footprint — the server-side
|
||||
/// <see cref="BundleSession"/> opened by <see cref="IBundleImporter.LoadAsync"/>
|
||||
/// holds the DECRYPTED bundle content and occupies one of
|
||||
/// <c>TransportOptions.MaxConcurrentImportSessions</c> slots for the full
|
||||
/// <c>BundleSessionTtlMinutes</c>. Only <see cref="IBundleImporter.ApplyAsync"/>
|
||||
/// used to release it, so eight abandoned wizards locked out every import on the
|
||||
/// active node. Every path that drops <c>_session</c> — new upload, Back-to-Upload,
|
||||
/// <see cref="ResetSessionState"/>, lockout reset, circuit teardown via
|
||||
/// <see cref="Dispose"/> — now goes through <see cref="ReleaseSession"/>, which
|
||||
/// calls <see cref="IBundleImporter.Abandon"/> (zero + remove, idempotent and
|
||||
/// safe after a successful Apply has already released the same id).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class TransportImport : ComponentBase, IDisposable
|
||||
{
|
||||
@@ -71,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!;
|
||||
@@ -149,7 +169,9 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
{
|
||||
_errorMessage = null;
|
||||
_uploadInProgress = true;
|
||||
_session = null;
|
||||
// Release the previous upload's server-side session before we lose the
|
||||
// only reference to its id — otherwise re-selecting a file leaks a slot.
|
||||
ReleaseSession();
|
||||
DeleteBundleTempFile();
|
||||
try
|
||||
{
|
||||
@@ -207,6 +229,10 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
_errorMessage = "No bundle staged — please re-select the file.";
|
||||
return;
|
||||
}
|
||||
// A successful LoadAsync overwrites _session; release whatever it is
|
||||
// replacing first so the id of the outgoing session is never dropped on
|
||||
// the floor (that would strand its slot for the full TTL).
|
||||
ReleaseSession();
|
||||
try
|
||||
{
|
||||
// Read the staged bundle straight off disk; the
|
||||
@@ -215,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)
|
||||
@@ -232,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;
|
||||
@@ -309,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()
|
||||
{
|
||||
@@ -339,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++;
|
||||
@@ -808,7 +861,7 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
|
||||
private void ResetSessionState()
|
||||
{
|
||||
_session = null;
|
||||
ReleaseSession();
|
||||
DeleteBundleTempFile();
|
||||
_preview = null;
|
||||
_resolutions = null;
|
||||
@@ -854,12 +907,41 @@ public partial class TransportImport : ComponentBase, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the staged temp file does not survive circuit
|
||||
/// teardown. Blazor invokes Dispose when the user navigates away or the
|
||||
/// circuit ends, so an abandoned wizard cleans up automatically.
|
||||
/// Releases the server-side <see cref="BundleSession"/> this wizard opened
|
||||
/// (zeroing its decrypted content) and clears the local reference. Idempotent:
|
||||
/// a null <c>_session</c> is a no-op, and <see cref="IBundleImporter.Abandon"/>
|
||||
/// tolerates an id that <see cref="IBundleImporter.ApplyAsync"/> already
|
||||
/// released. Only ever passes the id THIS circuit opened, so a concurrent
|
||||
/// operator's session is never touched.
|
||||
/// <para>
|
||||
/// Abandon failure must not block the wizard (or, from <see cref="Dispose"/>,
|
||||
/// tear down the circuit), so it is swallowed on the same best-effort footing
|
||||
/// as the staged-file delete below.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ReleaseSession()
|
||||
{
|
||||
var session = _session;
|
||||
_session = null;
|
||||
if (session is null) return;
|
||||
try
|
||||
{
|
||||
BundleImporter.Abandon(session.SessionId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: the session's own TTL is the backstop.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures neither the staged temp file nor the server-side bundle session
|
||||
/// survives circuit teardown. Blazor invokes Dispose when the user navigates
|
||||
/// away or the circuit ends, so an abandoned wizard cleans up automatically.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
ReleaseSession();
|
||||
DeleteBundleTempFile();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -10,8 +10,20 @@ public interface IBundleImporter
|
||||
/// <param name="bundleStream">Stream containing the bundle zip archive.</param>
|
||||
/// <param name="passphrase">Optional passphrase for decrypting an encrypted bundle.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="clientKey">
|
||||
/// Identity of the calling client — the remote IP — used to enforce the
|
||||
/// per-IP-per-hour unlock throttle (<c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>)
|
||||
/// on encrypted bundles. Trailing optional so unencrypted / in-process callers
|
||||
/// need not supply one; null or blank counts the attempt against a single shared
|
||||
/// "unattributed caller" bucket rather than skipping the throttle, so a caller
|
||||
/// that cannot be attributed can never spend an unbounded budget.
|
||||
/// </param>
|
||||
/// <returns>A task that resolves to session metadata for the loaded bundle.</returns>
|
||||
Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
|
||||
Task<BundleSession> LoadAsync(
|
||||
Stream bundleStream,
|
||||
string? passphrase,
|
||||
CancellationToken ct = default,
|
||||
string? clientKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Diffs the loaded bundle against the target database and returns a per-artifact preview.
|
||||
@@ -46,4 +58,25 @@ public interface IBundleImporter
|
||||
string user,
|
||||
CancellationToken ct = default,
|
||||
BundleNameMap? nameMap = null);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a session opened by <see cref="LoadAsync"/> that will never be
|
||||
/// applied — the operator navigated away, restarted the wizard, or a CLI
|
||||
/// preview/import gave up before <see cref="ApplyAsync"/> ran. Zeroes the
|
||||
/// session's decrypted plaintext and drops it from the session store, which
|
||||
/// frees one of the <c>MaxConcurrentImportSessions</c> slots immediately
|
||||
/// instead of at the end of <c>BundleSessionTtlMinutes</c>.
|
||||
/// <para>
|
||||
/// Idempotent and safe to call after <see cref="ApplyAsync"/> (which performs
|
||||
/// the same release on both its success and failure paths) or with an unknown
|
||||
/// / already-expired id — the call is then a no-op and never throws.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ownership: session ids are random GUIDs handed only to the caller that
|
||||
/// opened them, so abandoning the id you hold can never release a session
|
||||
/// another circuit / CLI caller owns.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Session id returned by <see cref="LoadAsync"/>.</param>
|
||||
void Abandon(Guid sessionId);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,22 @@ public record AuthenticatedUser(
|
||||
string Username, string DisplayName,
|
||||
string[] Roles, string[] PermittedSiteIds);
|
||||
|
||||
public record ManagementEnvelope(AuthenticatedUser User, object Command, string CorrelationId);
|
||||
/// <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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// T-004 / register row 44: thrown by <c>IBundleImporter.LoadAsync</c> when a client has
|
||||
/// spent its trailing-hour bundle-unlock budget (<c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
|
||||
/// default 10) as measured by <c>BundleUnlockRateLimiter</c>.
|
||||
/// <para>
|
||||
/// Distinct from <c>BundleLockedException</c> on purpose: that one means "this BUNDLE has
|
||||
/// burned its 3 strikes" and is keyed by the bundle's content hash; this one means "this
|
||||
/// CLIENT has attempted too many unlocks, across any bundles" and is keyed by the caller's
|
||||
/// IP. A throttled attempt never reaches the decryptor, so it deliberately burns no
|
||||
/// per-bundle strike — otherwise the throttle could be used to lock out a bundle an
|
||||
/// operator is legitimately importing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lives in Commons rather than beside the importer because it is part of the
|
||||
/// <c>IBundleImporter.LoadAsync</c> contract, which is declared here: the management
|
||||
/// surface (which references Commons but not Transport) must be able to catch it to
|
||||
/// return a clear, curated error instead of a generic internal-failure message.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleUnlockThrottledException : Exception
|
||||
{
|
||||
/// <summary>The rate-limiter key the attempt was counted against — the client IP, or the shared unattributed-caller bucket.</summary>
|
||||
public string ClientKey { get; }
|
||||
|
||||
/// <summary>The configured trailing-window cap that was reached.</summary>
|
||||
public int MaxAttemptsPerWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleUnlockThrottledException"/>.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">The rate-limiter key (client IP, or the shared unattributed bucket).</param>
|
||||
/// <param name="maxAttemptsPerWindow">The configured per-window cap that was reached.</param>
|
||||
public BundleUnlockThrottledException(string clientKey, int maxAttemptsPerWindow)
|
||||
: base(
|
||||
$"Too many bundle unlock attempts ({maxAttemptsPerWindow} per hour) from this client. "
|
||||
+ "Wait for the hour window to roll forward before trying again.")
|
||||
{
|
||||
ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey));
|
||||
MaxAttemptsPerWindow = maxAttemptsPerWindow;
|
||||
}
|
||||
}
|
||||
@@ -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,17 +3571,35 @@ 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);
|
||||
}
|
||||
|
||||
var preview = await importer.PreviewAsync(session.SessionId);
|
||||
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
|
||||
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
|
||||
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
|
||||
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
|
||||
// Surface the required site/connection mappings so an operator
|
||||
// (CLI or UI) sees which references need resolving before applying.
|
||||
return new PreviewBundleResult(
|
||||
preview.Items, adds, mods, ids, blocks,
|
||||
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
|
||||
// Register row 43: a preview NEVER applies, so nothing else would ever
|
||||
// release this session — every `transport preview` used to burn one of
|
||||
// MaxConcurrentImportSessions (and hold the decrypted bundle) for the
|
||||
// full BundleSessionTtlMinutes. Release it on the way out, always.
|
||||
try
|
||||
{
|
||||
var preview = await importer.PreviewAsync(session.SessionId);
|
||||
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
|
||||
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
|
||||
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
|
||||
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
|
||||
// Surface the required site/connection mappings so an operator
|
||||
// (CLI or UI) sees which references need resolving before applying.
|
||||
return new PreviewBundleResult(
|
||||
preview.Items, adds, mods, ids, blocks,
|
||||
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
|
||||
}
|
||||
finally
|
||||
{
|
||||
importer.Abandon(session.SessionId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -3594,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
|
||||
@@ -3607,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)
|
||||
{
|
||||
@@ -3617,51 +3636,70 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||||
}
|
||||
|
||||
var preview = await importer.PreviewAsync(session.SessionId);
|
||||
|
||||
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
|
||||
if (blockers.Count > 0)
|
||||
catch (BundleUnlockThrottledException ex)
|
||||
{
|
||||
var details = string.Join("; ",
|
||||
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
|
||||
throw new ManagementCommandException(
|
||||
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Resolve every required site/connection reference into a
|
||||
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
|
||||
// fall back to the preview's auto-match; otherwise (no match) create-new
|
||||
// ONLY if the create-missing flag is set, else fail with a clear message.
|
||||
var nameMap = BuildNameMap(cmd, preview);
|
||||
|
||||
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
|
||||
// entity (e.g. one row per modified member of a template), but ApplyAsync
|
||||
// requires a unique resolution per key. Last-write-wins matches the
|
||||
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
|
||||
// DataConnection rows the preview item Name is already site-qualified
|
||||
// ({site}/{name}), so keying generically by (EntityType, Name) is
|
||||
// automatically correct — no bare-connection-name special case needed.
|
||||
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
|
||||
foreach (var item in preview.Items)
|
||||
// Register row 43: ApplyAsync releases the session on both its success
|
||||
// and failure paths, but every abort BEFORE it (blockers, unresolved
|
||||
// mappings) used to strand the session — and its decrypted content —
|
||||
// for the full TTL. Abandon is idempotent, so covering the whole block
|
||||
// is safe even when ApplyAsync already released the same id.
|
||||
try
|
||||
{
|
||||
var action = item.Kind switch
|
||||
var preview = await importer.PreviewAsync(session.SessionId);
|
||||
|
||||
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
|
||||
if (blockers.Count > 0)
|
||||
{
|
||||
ConflictKind.New => ResolutionAction.Add,
|
||||
ConflictKind.Identical => ResolutionAction.Skip,
|
||||
ConflictKind.Modified => policy,
|
||||
_ => ResolutionAction.Skip,
|
||||
};
|
||||
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
|
||||
? $"{item.Name}-imported-{renameStamp}"
|
||||
: null;
|
||||
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
|
||||
item.EntityType, item.Name, action, renameTo);
|
||||
}
|
||||
var details = string.Join("; ",
|
||||
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
|
||||
throw new ManagementCommandException(
|
||||
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
|
||||
}
|
||||
|
||||
return await importer.ApplyAsync(
|
||||
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
|
||||
// Resolve every required site/connection reference into a
|
||||
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
|
||||
// fall back to the preview's auto-match; otherwise (no match) create-new
|
||||
// ONLY if the create-missing flag is set, else fail with a clear message.
|
||||
var nameMap = BuildNameMap(cmd, preview);
|
||||
|
||||
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
|
||||
// entity (e.g. one row per modified member of a template), but ApplyAsync
|
||||
// requires a unique resolution per key. Last-write-wins matches the
|
||||
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
|
||||
// DataConnection rows the preview item Name is already site-qualified
|
||||
// ({site}/{name}), so keying generically by (EntityType, Name) is
|
||||
// automatically correct — no bare-connection-name special case needed.
|
||||
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
|
||||
foreach (var item in preview.Items)
|
||||
{
|
||||
var action = item.Kind switch
|
||||
{
|
||||
ConflictKind.New => ResolutionAction.Add,
|
||||
ConflictKind.Identical => ResolutionAction.Skip,
|
||||
ConflictKind.Modified => policy,
|
||||
_ => ResolutionAction.Skip,
|
||||
};
|
||||
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
|
||||
? $"{item.Name}-imported-{renameStamp}"
|
||||
: null;
|
||||
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
|
||||
item.EntityType, item.Name, action, renameTo);
|
||||
}
|
||||
|
||||
return await importer.ApplyAsync(
|
||||
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
|
||||
}
|
||||
finally
|
||||
{
|
||||
importer.Abandon(session.SessionId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -134,7 +134,14 @@ public static class ManagementEndpoints
|
||||
}
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var envelope = new ManagementEnvelope(authenticatedUser, command, correlationId);
|
||||
// Carry the caller's remote IP so the Transport bundle handlers can enforce
|
||||
// the per-IP-per-hour unlock throttle (register row 44). ForwardedHeaders has
|
||||
// already rewritten this from X-Forwarded-For for CONFIGURED proxies only
|
||||
// (Program.cs), so it is a real client address in the shipped topologies and
|
||||
// the proxy's address behind an untrusted one.
|
||||
var envelope = new ManagementEnvelope(
|
||||
authenticatedUser, command, correlationId,
|
||||
context.Connection.RemoteIpAddress?.ToString());
|
||||
|
||||
var askTimeout = ResolveAskTimeout(
|
||||
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value,
|
||||
|
||||
@@ -165,8 +165,22 @@ public sealed class BundleImporter : IBundleImporter
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-004 / register row 44: the rate-limiter bucket used when a caller supplies no
|
||||
/// client key. Deliberately a single SHARED key rather than a per-call bypass — an
|
||||
/// unattributable caller is throttled together with every other unattributable
|
||||
/// caller, so "no IP available" degrades to a stricter budget, never to none. A
|
||||
/// production unlock always carries an IP (Central UI circuit, management endpoint),
|
||||
/// so in practice this bucket only serves in-process callers and tests.
|
||||
/// </summary>
|
||||
internal const string UnattributedClientKey = "<unattributed>";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
|
||||
public async Task<BundleSession> LoadAsync(
|
||||
Stream bundleStream,
|
||||
string? passphrase,
|
||||
CancellationToken ct = default,
|
||||
string? clientKey = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleStream);
|
||||
|
||||
@@ -264,6 +278,33 @@ public sealed class BundleImporter : IBundleImporter
|
||||
throw new BundleLockedException(manifest.ContentHash, priorFailures);
|
||||
}
|
||||
|
||||
// T-004 / register row 44: the documented per-IP-per-hour cap. The limiter
|
||||
// was registered, injected and unit-tested but never invoked, so a
|
||||
// brute-force attacker against a stolen bundle was bounded only by the
|
||||
// per-bundle 3-strike lockout (side-steppable by re-exporting the bundle
|
||||
// for a fresh ContentHash).
|
||||
//
|
||||
// Ordering is load-bearing:
|
||||
// * AFTER the per-bundle lockout check, so hammering an already-locked
|
||||
// bundle does not spend the client's hourly budget on rejected calls.
|
||||
// * BEFORE the decrypt, so a THROTTLED attempt never reaches
|
||||
// IncrementUnlockFailureCount — a denial must not burn a per-bundle
|
||||
// strike, or the throttle would become a way to lock a bundle that an
|
||||
// operator is legitimately importing.
|
||||
// Registered on every unlock attempt (successful ones included) because the
|
||||
// documented budget counts attempts, not failures.
|
||||
var throttleKey = string.IsNullOrWhiteSpace(clientKey)
|
||||
? UnattributedClientKey
|
||||
: clientKey;
|
||||
var perIpCap = _options.Value.MaxUnlockAttemptsPerIpPerHour;
|
||||
if (!_unlockRateLimiter.TryRegisterAttempt(throttleKey, perIpCap))
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Bundle unlock throttled: client {ClientKey} exceeded {Cap} attempts in the trailing hour.",
|
||||
throttleKey, perIpCap);
|
||||
throw new BundleUnlockThrottledException(throttleKey, perIpCap);
|
||||
}
|
||||
|
||||
var aad = Encryption.BundleManifestAad.Compute(manifest);
|
||||
try
|
||||
{
|
||||
@@ -1715,6 +1756,32 @@ public sealed class BundleImporter : IBundleImporter
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Register row 43: every abandonment path (Central UI wizard reset /
|
||||
/// back-to-upload / circuit teardown, CLI preview, CLI import that aborts
|
||||
/// before Apply) funnels here so a session that will never be applied stops
|
||||
/// holding one of the <c>MaxConcurrentImportSessions</c> slots — and stops
|
||||
/// holding its decrypted plaintext — for the rest of its TTL. Applies the same
|
||||
/// zero-then-remove ordering as the T-007 release inside
|
||||
/// <see cref="ApplyAsync"/>: zeroing first means a caller still holding the
|
||||
/// session reference (the Razor page keeps one for the Result step) observes
|
||||
/// the cleared buffer too.
|
||||
/// </remarks>
|
||||
public void Abandon(Guid sessionId)
|
||||
{
|
||||
// Get() returns null for an unknown or already-expired id, which makes the
|
||||
// double-release after ApplyAsync (which already removed it) a no-op rather
|
||||
// than a throw. Remove is called unconditionally so an expired-but-still-
|
||||
// resident entry is dropped as well.
|
||||
var session = _sessionStore.Get(sessionId);
|
||||
if (session is not null)
|
||||
{
|
||||
ZeroDecryptedContent(session);
|
||||
}
|
||||
_sessionStore.Remove(sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the deployed instances stale-ed by this import.
|
||||
/// Overwriting a template changes the flattened-config hash of every instance
|
||||
|
||||
Reference in New Issue
Block a user