Merge branch 'transport-session-fixes' — abandoned-session release + per-IP unlock throttle enforced (register rows 43, 44)
This commit is contained in:
@@ -39,7 +39,15 @@ When a passphrase is supplied, `BundleSecretEncryptor` derives a 256-bit key via
|
|||||||
|
|
||||||
### BundleSession and session lifecycle
|
### BundleSession and session lifecycle
|
||||||
|
|
||||||
After `LoadAsync` validates and decrypts a bundle, the plaintext content is stored in a `BundleSession` held by the singleton `BundleSessionStore` (a `ConcurrentDictionary<Guid, BundleSession>`). Sessions have a 30-minute TTL (`BundleSessionTtlMinutes`). `BundleSessionEvictionService` sweeps the store every minute so abandoned sessions — and the secrets they carry — are released without waiting for the next `Get` call. `ApplyAsync` explicitly zeros and removes the session on both success and failure (T-007).
|
After `LoadAsync` validates and decrypts a bundle, the plaintext content is stored in a `BundleSession` held by the singleton `BundleSessionStore` (a `ConcurrentDictionary<Guid, BundleSession>`). Sessions have a 30-minute TTL (`BundleSessionTtlMinutes`). `BundleSessionEvictionService` sweeps the store every minute, but that only reclaims sessions whose TTL has already **elapsed** — it is a backstop, not an abandonment path. `ApplyAsync` explicitly zeros and removes the session on both success and failure (T-007).
|
||||||
|
|
||||||
|
`IBundleImporter.Abandon(sessionId)` is the explicit release for a session that will never be applied, applying the same zero-then-remove ordering as `ApplyAsync`. It is idempotent (double release, unknown id, and already-expired id are all silent no-ops) and, because session ids are random GUIDs handed only to their opener, can never release another caller's session. Every abandonment path calls it:
|
||||||
|
|
||||||
|
- Central UI wizard — new upload, Back-to-Upload / `ResetSessionState`, the 3-strike lockout reset, and `Dispose` (navigation away or circuit teardown).
|
||||||
|
- CLI `transport preview` — a preview never applies, so the handler releases in a `finally`.
|
||||||
|
- CLI `transport import` — covers the aborts that happen before `ApplyAsync` (blocker rows, unresolved site/connection mappings).
|
||||||
|
|
||||||
|
Without this, an abandoned wizard or a plain CLI preview held one of the eight `MaxConcurrentImportSessions` slots — and the decrypted bundle content — for the full TTL; eight of them locked out every import on the active node (deferred register row 43, observed live).
|
||||||
|
|
||||||
### Conflict resolution and `BundleImportId` correlation
|
### Conflict resolution and `BundleImportId` correlation
|
||||||
|
|
||||||
@@ -110,7 +118,15 @@ Inbound API keys are explicitly excluded — per re-architecture C4, keys are en
|
|||||||
|
|
||||||
**Phase 1 — `LoadAsync`**: copies the upload stream to a seekable `MemoryStream`, enforces the bundle size cap (`MaxBundleSizeMb`), validates the ZIP envelope (entry count, per-entry decompressed size, compression ratio — all before decompression), reads and validates `manifest.json` (format version, SHA-256 content hash), and decrypts `content.enc` when `Encryption` is present. The decrypted bytes are stored in a new `BundleSession`.
|
**Phase 1 — `LoadAsync`**: copies the upload stream to a seekable `MemoryStream`, enforces the bundle size cap (`MaxBundleSizeMb`), validates the ZIP envelope (entry count, per-entry decompressed size, compression ratio — all before decompression), reads and validates `manifest.json` (format version, SHA-256 content hash), and decrypts `content.enc` when `Encryption` is present. The decrypted bytes are stored in a new `BundleSession`.
|
||||||
|
|
||||||
Passphrase lockout operates at two levels: per-bundle (3-strike counter in `BundleSessionStore`, keyed by `ContentHash` so a second browser tab sharing the same bundle bytes cannot reset the counter) and per-IP-per-hour (`BundleUnlockRateLimiter`, default 10 attempts). A successful decrypt clears the per-bundle counter.
|
Passphrase lockout operates at two levels: per-bundle (3-strike counter in `BundleSessionStore`, keyed by `ContentHash` so a second browser tab sharing the same bundle bytes cannot reset the counter) and per-IP-per-hour (`BundleUnlockRateLimiter`, `MaxUnlockAttemptsPerIpPerHour`, default 10 attempts). A successful decrypt clears the per-bundle counter.
|
||||||
|
|
||||||
|
The two levels interact in a specific order, and it is load-bearing (register row 44 — the limiter had been registered, injected and unit-tested but never called, so only the per-bundle lockout was real):
|
||||||
|
|
||||||
|
1. **Per-bundle lockout first.** An already-locked bundle throws `BundleLockedException` without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget.
|
||||||
|
2. **Per-IP throttle next, before the decrypt.** Over budget throws `BundleUnlockThrottledException` (Commons, so the management surface can catch it and return a curated message). Because it precedes the decrypt, a denial burns **no** per-bundle strike — otherwise the throttle would be a way to lock out a bundle someone is legitimately importing. Every unlock attempt is registered, successful ones included: the documented budget counts attempts, not failures.
|
||||||
|
3. Only then the AES-GCM decrypt, whose failure increments the per-bundle counter as before.
|
||||||
|
|
||||||
|
The client key is the caller's remote IP: the Central UI wizard reads it from `CircuitClientAddressProvider` (a scoped service that snapshots `Connection.RemoteIpAddress` at circuit establishment, since `HttpContext` is gone by the time a circuit submits a passphrase — the same pattern as `CookieAuthenticationStateProvider`), and the management endpoint stamps it on `ManagementEnvelope.ClientIp` for the CLI `transport preview` / `transport import` paths. A caller with no attributable address is counted against a single **shared** bucket rather than skipping the throttle — "unknown client" degrades to a stricter budget, never to none. As with the login throttle (`Component-Security.md`), per-client isolation is only real when ForwardedHeaders is configured for the fronting proxy; behind an untrusted proxy every caller collapses into the proxy's bucket.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// From BundleImporter.LoadAsync — decrypt path (simplified)
|
// From BundleImporter.LoadAsync — decrypt path (simplified)
|
||||||
@@ -241,6 +257,10 @@ A template's scripts reference a `SharedScript` or `ExternalSystem` that exists
|
|||||||
|
|
||||||
After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt.
|
After 3 wrong passphrase attempts against the same bundle (keyed by `ContentHash`), `BundleImporter.LoadAsync` throws `BundleLockedException`. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A `BundleImportUnlockFailed` audit row is written on each failed attempt.
|
||||||
|
|
||||||
|
### Unlock throttled ("Too many bundle unlock attempts")
|
||||||
|
|
||||||
|
`BundleUnlockThrottledException` — this **client** (not this bundle) has used its `MaxUnlockAttemptsPerIpPerHour` budget within the trailing hour. Re-uploading does not help; the budget is keyed by IP and rolls forward on a sliding window, and the counters are process-local (a central node restart clears them, deliberately, so an outage doesn't strand legitimate operators). The denial burns no per-bundle strike, so the bundle itself is still importable once the window frees up. Raise `ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour` if a legitimate workflow (many encrypted bundles in one sitting) trips it routinely.
|
||||||
|
|
||||||
### BundleImportAlarmScriptUnresolved / BundleImportCompositionUnresolved warnings
|
### BundleImportAlarmScriptUnresolved / BundleImportCompositionUnresolved warnings
|
||||||
|
|
||||||
These audit rows appear when the second-pass rewire (`ResolveAlarmScriptLinksAsync` / `ResolveCompositionEdgesAsync`) cannot match a name to a persisted row. The import commits — the FK is left null / the composition row is skipped — but the warning signals an incomplete import. Re-examine the bundle's dependency graph and re-export with the missing artifacts included.
|
These audit rows appear when the second-pass rewire (`ResolveAlarmScriptLinksAsync` / `ResolveCompositionEdgesAsync`) cannot match a name to a persisted row. The import commits — the FK is left null / the composition row is skipped — but the warning signals an incomplete import. Re-examine the bundle's dependency graph and re-export with the missing artifacts included.
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph T["ZB.MOM.WW.ScadaBridge.Transport"]
|
subgraph T["ZB.MOM.WW.ScadaBridge.Transport"]
|
||||||
EXPORTER["IBundleExporter<br/>ExportAsync(ExportSelection, Passphrase?, ct) → Stream"]
|
EXPORTER["IBundleExporter<br/>ExportAsync(ExportSelection, Passphrase?, ct) → Stream"]
|
||||||
IMPORTER["IBundleImporter<br/>LoadAsync(stream, Passphrase?, ct) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult"]
|
IMPORTER["IBundleImporter<br/>LoadAsync(stream, Passphrase?, ct, ClientIp?) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult<br/>Abandon(sessionId)"]
|
||||||
RESOLVER["DependencyResolver"]
|
RESOLVER["DependencyResolver"]
|
||||||
SERIALIZER["BundleSerializer<br/>(manifest + content JSON; ZIP packer)"]
|
SERIALIZER["BundleSerializer<br/>(manifest + content JSON; ZIP packer)"]
|
||||||
ENCRYPTOR["BundleSecretEncryptor<br/>(AES-256-GCM + PBKDF2)"]
|
ENCRYPTOR["BundleSecretEncryptor<br/>(AES-256-GCM + PBKDF2)"]
|
||||||
@@ -136,6 +136,8 @@ flowchart TD
|
|||||||
|
|
||||||
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
|
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
|
||||||
|
|
||||||
|
**Session release is explicit, not TTL-only (register row 43).** `ApplyAsync` releases the session it applied (zero-then-remove, T-007), and `IBundleImporter.Abandon(sessionId)` does the same for a session that will never be applied. Every abandonment path calls it: the Central UI wizard on new upload / Back-to-Upload / `ResetSessionState` / lockout reset / `Dispose` (navigation away or circuit teardown), the CLI `transport preview` handler (a preview never applies), and the CLI `transport import` handler for the aborts that precede apply (blocker rows, unresolved mappings). `Abandon` is idempotent — double release, an unknown id, and an already-expired id are all silent no-ops — and since session ids are random GUIDs handed only to their opener, abandoning the id you hold can never evict another operator's in-progress import. Relying on the TTL alone was an availability *and* secret-lifetime defect: eight abandoned wizards exhausted `MaxConcurrentImportSessions` and locked out every import on the active node for 30 minutes, each still holding its decrypted bundle content.
|
||||||
|
|
||||||
**Preview→apply window (recorded decision).** The diff/preview an operator reviews is a point-in-time snapshot; there is **no optimistic concurrency** between preview and apply. If the target config changes in the window between `PreviewAsync` and `ApplyAsync` (another admin edits a template, say), the apply proceeds against the current state — the resolutions are keyed by `(EntityType, Name)`, so they still target the right rows, but the operator may not have seen the latest state in the diff. This is accepted for v1 (single-operator import is the expected workflow, and the terminal Confirm step already gates a typo-resistant environment-name entry). The bundle-format `version` fields are reserved for a future optimistic-concurrency check but are not yet enforced (Underdeveloped 5).
|
**Preview→apply window (recorded decision).** The diff/preview an operator reviews is a point-in-time snapshot; there is **no optimistic concurrency** between preview and apply. If the target config changes in the window between `PreviewAsync` and `ApplyAsync` (another admin edits a template, say), the apply proceeds against the current state — the resolutions are keyed by `(EntityType, Name)`, so they still target the right rows, but the operator may not have seen the latest state in the diff. This is accepted for v1 (single-operator import is the expected workflow, and the terminal Confirm step already gates a typo-resistant environment-name entry). The bundle-format `version` fields are reserved for a future optimistic-concurrency check but are not yet enforced (Underdeveloped 5).
|
||||||
|
|
||||||
## Export Flow
|
## Export Flow
|
||||||
@@ -334,7 +336,7 @@ Imports are all-or-nothing per bundle. A bundle either applies fully or not at a
|
|||||||
|
|
||||||
- **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly.
|
- **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly.
|
||||||
- **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line.
|
- **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line.
|
||||||
- **Failed-unlock rate limit:** per-session 3-strike lockout; per-IP-per-hour cap (default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row.
|
- **Failed-unlock rate limit:** per-bundle 3-strike lockout (keyed by `ContentHash`, server-side); per-IP-per-hour cap (`MaxUnlockAttemptsPerIpPerHour`, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (`BundleUnlockThrottledException`, fail-closed with a curated message on both the UI and CLI surfaces) burns **no** per-bundle strike, since it never reaches the decryptor. Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — from `CircuitClientAddressProvider` (snapshotted at Blazor circuit establishment, since `HttpContext` is unavailable mid-circuit) for the Central UI, and from `ManagementEnvelope.ClientIp` for the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries in `Component-Security.md`. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.)
|
||||||
- **Bundle size cap** on upload (default 100 MB, configurable) to bound memory.
|
- **Bundle size cap** on upload (default 100 MB, configurable) to bound memory.
|
||||||
- **In-transit:** existing HTTPS to the Central UI; no new channel.
|
- **In-transit:** existing HTTPS to the Central UI; no new channel.
|
||||||
- **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary.
|
- **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary.
|
||||||
|
|||||||
@@ -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 /
|
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
|
||||||
/// successful Apply / component Dispose, so an abandoned wizard does not leak
|
/// successful Apply / component Dispose, so an abandoned wizard does not leak
|
||||||
/// staged bundle plaintext beyond circuit teardown.
|
/// 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>
|
/// </summary>
|
||||||
public partial class TransportImport : ComponentBase, IDisposable
|
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 IAuditService AuditService { get; set; } = default!;
|
||||||
[Inject] private ScadaBridgeDbContext DbContext { 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
|
// The Map step needs the destination environment's sites + each
|
||||||
// site's connections to populate the "map to existing target" dropdowns.
|
// site's connections to populate the "map to existing target" dropdowns.
|
||||||
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
|
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
|
||||||
@@ -149,7 +169,9 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
{
|
{
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_uploadInProgress = true;
|
_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();
|
DeleteBundleTempFile();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -207,6 +229,10 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
_errorMessage = "No bundle staged — please re-select the file.";
|
_errorMessage = "No bundle staged — please re-select the file.";
|
||||||
return;
|
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
|
try
|
||||||
{
|
{
|
||||||
// Read the staged bundle straight off disk; the
|
// Read the staged bundle straight off disk; the
|
||||||
@@ -215,7 +241,8 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
using var stream = new FileStream(
|
using var stream = new FileStream(
|
||||||
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||||
bufferSize: 81920, useAsync: true);
|
bufferSize: 81920, useAsync: true);
|
||||||
_session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
|
_session = await BundleImporter.LoadAsync(
|
||||||
|
stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
@@ -232,6 +259,14 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
_session = null;
|
_session = null;
|
||||||
throw;
|
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)
|
catch (InvalidDataException ex)
|
||||||
{
|
{
|
||||||
_session = null;
|
_session = null;
|
||||||
@@ -309,6 +344,11 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
/// shared across tabs / CLI / circuits). The Razor counter is kept ONLY for
|
/// 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.
|
/// display ("3 of N attempts used") — it is no longer the source of truth.
|
||||||
/// </para>
|
/// </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>
|
/// </summary>
|
||||||
private async Task SubmitPassphraseAsync()
|
private async Task SubmitPassphraseAsync()
|
||||||
{
|
{
|
||||||
@@ -339,6 +379,19 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
ResetSessionState();
|
ResetSessionState();
|
||||||
_step = ImportWizardStep.Upload;
|
_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)
|
catch (CryptographicException ex)
|
||||||
{
|
{
|
||||||
_failedUnlockAttempts++;
|
_failedUnlockAttempts++;
|
||||||
@@ -808,7 +861,7 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
|
|
||||||
private void ResetSessionState()
|
private void ResetSessionState()
|
||||||
{
|
{
|
||||||
_session = null;
|
ReleaseSession();
|
||||||
DeleteBundleTempFile();
|
DeleteBundleTempFile();
|
||||||
_preview = null;
|
_preview = null;
|
||||||
_resolutions = null;
|
_resolutions = null;
|
||||||
@@ -854,12 +907,41 @@ public partial class TransportImport : ComponentBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensures the staged temp file does not survive circuit
|
/// Releases the server-side <see cref="BundleSession"/> this wizard opened
|
||||||
/// teardown. Blazor invokes Dispose when the user navigates away or the
|
/// (zeroing its decrypted content) and clears the local reference. Idempotent:
|
||||||
/// circuit ends, so an abandoned wizard cleans up automatically.
|
/// 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>
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
ReleaseSession();
|
||||||
DeleteBundleTempFile();
|
DeleteBundleTempFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
||||||
services.AddCascadingAuthenticationState();
|
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
|
// Resolves the current user's permitted site set from their SiteId claims
|
||||||
// so Deployment/Monitoring pages can enforce site scoping.
|
// so Deployment/Monitoring pages can enforce site scoping.
|
||||||
services.AddScoped<SiteScopeService>();
|
services.AddScoped<SiteScopeService>();
|
||||||
|
|||||||
@@ -10,8 +10,20 @@ public interface IBundleImporter
|
|||||||
/// <param name="bundleStream">Stream containing the bundle zip archive.</param>
|
/// <param name="bundleStream">Stream containing the bundle zip archive.</param>
|
||||||
/// <param name="passphrase">Optional passphrase for decrypting an encrypted bundle.</param>
|
/// <param name="passphrase">Optional passphrase for decrypting an encrypted bundle.</param>
|
||||||
/// <param name="ct">Cancellation token.</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>
|
/// <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>
|
/// <summary>
|
||||||
/// Diffs the loaded bundle against the target database and returns a per-artifact preview.
|
/// Diffs the loaded bundle against the target database and returns a per-artifact preview.
|
||||||
@@ -46,4 +58,25 @@ public interface IBundleImporter
|
|||||||
string user,
|
string user,
|
||||||
CancellationToken ct = default,
|
CancellationToken ct = default,
|
||||||
BundleNameMap? nameMap = null);
|
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 Username, string DisplayName,
|
||||||
string[] Roles, string[] PermittedSiteIds);
|
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 ManagementSuccess(string CorrelationId, string JsonData);
|
||||||
public record ManagementError(string CorrelationId, string Error, string ErrorCode);
|
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)
|
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
|
||||||
{
|
{
|
||||||
using var scope = _serviceProvider.CreateScope();
|
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));
|
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +294,8 @@ public class ManagementActor : ReceiveActor
|
|||||||
_ => null
|
_ => 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
|
return command switch
|
||||||
{
|
{
|
||||||
@@ -474,8 +475,8 @@ public class ManagementActor : ReceiveActor
|
|||||||
|
|
||||||
// Transport bundle operations
|
// Transport bundle operations
|
||||||
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
|
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
|
||||||
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd),
|
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd, clientIp),
|
||||||
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username),
|
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username, clientIp),
|
||||||
|
|
||||||
// NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step
|
// NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step
|
||||||
// "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP
|
// "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.
|
/// not apply anything; callers can use this to gate Apply on the diff shape.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<object?> HandlePreviewBundle(
|
private static async Task<object?> HandlePreviewBundle(
|
||||||
IServiceProvider sp, PreviewBundleCommand cmd)
|
IServiceProvider sp, PreviewBundleCommand cmd, string? clientIp)
|
||||||
{
|
{
|
||||||
await TransportGate.WaitAsync();
|
await TransportGate.WaitAsync();
|
||||||
try
|
try
|
||||||
@@ -3560,7 +3561,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = new MemoryStream(bytes);
|
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)
|
catch (ArgumentException ex)
|
||||||
{
|
{
|
||||||
@@ -3570,17 +3571,35 @@ public class ManagementActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
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);
|
// Register row 43: a preview NEVER applies, so nothing else would ever
|
||||||
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
|
// release this session — every `transport preview` used to burn one of
|
||||||
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
|
// MaxConcurrentImportSessions (and hold the decrypted bundle) for the
|
||||||
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
|
// full BundleSessionTtlMinutes. Release it on the way out, always.
|
||||||
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
|
try
|
||||||
// Surface the required site/connection mappings so an operator
|
{
|
||||||
// (CLI or UI) sees which references need resolving before applying.
|
var preview = await importer.PreviewAsync(session.SessionId);
|
||||||
return new PreviewBundleResult(
|
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
|
||||||
preview.Items, adds, mods, ids, blocks,
|
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
|
||||||
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -3594,7 +3613,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
/// <see cref="ConflictKind.Blocker"/> rows fail the call before Apply.
|
/// <see cref="ConflictKind.Blocker"/> rows fail the call before Apply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<object?> HandleImportBundle(
|
private static async Task<object?> HandleImportBundle(
|
||||||
IServiceProvider sp, ImportBundleCommand cmd, string username)
|
IServiceProvider sp, ImportBundleCommand cmd, string username, string? clientIp)
|
||||||
{
|
{
|
||||||
await TransportGate.WaitAsync();
|
await TransportGate.WaitAsync();
|
||||||
try
|
try
|
||||||
@@ -3607,7 +3626,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = new MemoryStream(bytes);
|
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)
|
catch (ArgumentException ex)
|
||||||
{
|
{
|
||||||
@@ -3617,51 +3636,70 @@ public class ManagementActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||||||
}
|
}
|
||||||
|
catch (BundleUnlockThrottledException ex)
|
||||||
var preview = await importer.PreviewAsync(session.SessionId);
|
|
||||||
|
|
||||||
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
|
|
||||||
if (blockers.Count > 0)
|
|
||||||
{
|
{
|
||||||
var details = string.Join("; ",
|
// Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
|
||||||
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
|
// prints the reason instead of MapFault's generic internal-error
|
||||||
throw new ManagementCommandException(
|
// message; no per-bundle strike was burned.
|
||||||
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
|
throw new ManagementCommandException(ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve every required site/connection reference into a
|
// Register row 43: ApplyAsync releases the session on both its success
|
||||||
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
|
// and failure paths, but every abort BEFORE it (blockers, unresolved
|
||||||
// fall back to the preview's auto-match; otherwise (no match) create-new
|
// mappings) used to strand the session — and its decrypted content —
|
||||||
// ONLY if the create-missing flag is set, else fail with a clear message.
|
// for the full TTL. Abandon is idempotent, so covering the whole block
|
||||||
var nameMap = BuildNameMap(cmd, preview);
|
// is safe even when ApplyAsync already released the same id.
|
||||||
|
try
|
||||||
// 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
|
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,
|
var details = string.Join("; ",
|
||||||
ConflictKind.Identical => ResolutionAction.Skip,
|
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
|
||||||
ConflictKind.Modified => policy,
|
throw new ManagementCommandException(
|
||||||
_ => ResolutionAction.Skip,
|
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
|
||||||
};
|
}
|
||||||
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(
|
// Resolve every required site/connection reference into a
|
||||||
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
|
// 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
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -134,7 +134,14 @@ public static class ManagementEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var correlationId = Guid.NewGuid().ToString("N");
|
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(
|
var askTimeout = ResolveAskTimeout(
|
||||||
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value,
|
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value,
|
||||||
|
|||||||
@@ -165,8 +165,22 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
_logger = logger;
|
_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 />
|
/// <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);
|
ArgumentNullException.ThrowIfNull(bundleStream);
|
||||||
|
|
||||||
@@ -264,6 +278,33 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
throw new BundleLockedException(manifest.ContentHash, priorFailures);
|
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);
|
var aad = Encryption.BundleManifestAad.Compute(manifest);
|
||||||
try
|
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>
|
/// <summary>
|
||||||
/// Enumerates the deployed instances stale-ed by this import.
|
/// Enumerates the deployed instances stale-ed by this import.
|
||||||
/// Overwriting a template changes the flattened-config hash of every instance
|
/// Overwriting a template changes the flattened-config hash of every instance
|
||||||
|
|||||||
+198
-2
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
@@ -17,6 +18,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
|
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
|
||||||
@@ -44,6 +46,9 @@ public class TransportImportPageTests : BunitContext
|
|||||||
// the Map tests) can render the wizard without a missing-service failure.
|
// the Map tests) can render the wizard without a missing-service failure.
|
||||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
|
||||||
|
/// <summary>Client address the wizard reports for the per-IP unlock throttle (register row 44).</summary>
|
||||||
|
private const string TestClientIp = "203.0.113.7";
|
||||||
|
|
||||||
public TransportImportPageTests()
|
public TransportImportPageTests()
|
||||||
{
|
{
|
||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
@@ -51,6 +56,11 @@ public class TransportImportPageTests : BunitContext
|
|||||||
Services.AddSingleton(_importer);
|
Services.AddSingleton(_importer);
|
||||||
Services.AddSingleton(_auditService);
|
Services.AddSingleton(_auditService);
|
||||||
|
|
||||||
|
// Register row 44: the wizard passes the circuit's client IP to LoadAsync so
|
||||||
|
// the per-IP unlock throttle can attribute the attempt. bUnit has no HTTP
|
||||||
|
// context, so use the provider's explicit-address test seam.
|
||||||
|
Services.AddSingleton(new CircuitClientAddressProvider(TestClientIp));
|
||||||
|
|
||||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(Array.Empty<Site>());
|
.Returns(Array.Empty<Site>());
|
||||||
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
@@ -131,7 +141,7 @@ public class TransportImportPageTests : BunitContext
|
|||||||
public async Task Decryption_failure_increments_attempt_counter()
|
public async Task Decryption_failure_increments_attempt_counter()
|
||||||
{
|
{
|
||||||
// Set up the importer to throw CryptographicException for wrong passphrases.
|
// Set up the importer to throw CryptographicException for wrong passphrases.
|
||||||
_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?>())
|
||||||
.Throws(new CryptographicException("authentication tag mismatch"));
|
.Throws(new CryptographicException("authentication tag mismatch"));
|
||||||
|
|
||||||
var cut = Render<TransportImportPage>();
|
var cut = Render<TransportImportPage>();
|
||||||
@@ -161,7 +171,7 @@ public class TransportImportPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Three_failed_unlocks_force_reupload()
|
public async Task Three_failed_unlocks_force_reupload()
|
||||||
{
|
{
|
||||||
_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?>())
|
||||||
.Throws(new CryptographicException("authentication tag mismatch"));
|
.Throws(new CryptographicException("authentication tag mismatch"));
|
||||||
|
|
||||||
var cut = Render<TransportImportPage>();
|
var cut = Render<TransportImportPage>();
|
||||||
@@ -688,6 +698,192 @@ public class TransportImportPageTests : BunitContext
|
|||||||
Assert.Empty(connChoices);
|
Assert.Empty(connChoices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Register row 43: an abandoned wizard must not strand its server-side
|
||||||
|
// BundleSession. These tests swap the NSubstitute importer for one backed
|
||||||
|
// by a REAL BundleSessionStore so the assertion is "the slot is actually
|
||||||
|
// released", not "a mock was called".
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal <see cref="IBundleImporter"/> over a real <see cref="BundleSessionStore"/>.
|
||||||
|
/// <see cref="Abandon"/> and <see cref="ApplyAsync"/> mirror the production
|
||||||
|
/// importer's zero-then-remove release; every other member is unused by these
|
||||||
|
/// tests. Sessions are seeded directly with <see cref="OpenSession"/> because
|
||||||
|
/// driving <c>InputFile</c> from bUnit is impractical (see the class remarks).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StoreBackedImporter : IBundleImporter
|
||||||
|
{
|
||||||
|
public BundleSessionStore Store { get; } = new(
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new TransportOptions()),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
public int AbandonCalls { get; private set; }
|
||||||
|
|
||||||
|
public BundleSession OpenSession(string sourceEnv = "prod-cluster")
|
||||||
|
{
|
||||||
|
var seed = BuildEncryptedSession(sourceEnv);
|
||||||
|
var session = new BundleSession
|
||||||
|
{
|
||||||
|
SessionId = seed.SessionId,
|
||||||
|
Manifest = seed.Manifest,
|
||||||
|
// Non-zero bytes so the zeroing assertion is meaningful.
|
||||||
|
DecryptedContent = new byte[] { 0x11, 0x22, 0x33, 0x44 },
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||||
|
};
|
||||||
|
return Store.Open(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<BundleSession> LoadAsync(
|
||||||
|
Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
|
||||||
|
=> throw new NotSupportedException("These tests seed sessions via OpenSession().");
|
||||||
|
|
||||||
|
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new ImportPreview(sessionId, new List<ImportPreviewItem>()));
|
||||||
|
|
||||||
|
public Task<ImportResult> ApplyAsync(
|
||||||
|
Guid sessionId,
|
||||||
|
IReadOnlyList<ImportResolution> resolutions,
|
||||||
|
string user,
|
||||||
|
CancellationToken ct = default,
|
||||||
|
BundleNameMap? nameMap = null)
|
||||||
|
{
|
||||||
|
Release(sessionId);
|
||||||
|
return Task.FromResult(new ImportResult(
|
||||||
|
BundleImportId: Guid.NewGuid(),
|
||||||
|
Added: 1,
|
||||||
|
Overwritten: 0,
|
||||||
|
Skipped: 0,
|
||||||
|
Renamed: 0,
|
||||||
|
StaleInstanceIds: Array.Empty<int>(),
|
||||||
|
AuditEventCorrelation: Guid.NewGuid().ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Abandon(Guid sessionId)
|
||||||
|
{
|
||||||
|
AbandonCalls++;
|
||||||
|
Release(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Release(Guid sessionId)
|
||||||
|
{
|
||||||
|
var session = Store.Get(sessionId);
|
||||||
|
if (session is { DecryptedContent.Length: > 0 })
|
||||||
|
{
|
||||||
|
Array.Clear(session.DecryptedContent, 0, session.DecryptedContent.Length);
|
||||||
|
}
|
||||||
|
Store.Remove(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disposing_the_wizard_releases_its_bundle_session()
|
||||||
|
{
|
||||||
|
// Circuit teardown / navigation away. Before the fix, Dispose deleted only
|
||||||
|
// the staged temp file, so the session (and its decrypted content) held one
|
||||||
|
// of MaxConcurrentImportSessions for the full 30-minute TTL.
|
||||||
|
var importer = new StoreBackedImporter();
|
||||||
|
Services.AddSingleton<IBundleImporter>(importer);
|
||||||
|
|
||||||
|
var cut = Render<TransportImportPage>();
|
||||||
|
var session = importer.OpenSession();
|
||||||
|
var content = session.DecryptedContent;
|
||||||
|
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
||||||
|
|
||||||
|
cut.Instance.Dispose();
|
||||||
|
|
||||||
|
Assert.Null(importer.Store.Get(session.SessionId));
|
||||||
|
Assert.All(content, b => Assert.Equal(0, b));
|
||||||
|
Assert.Null(GetField<object?>(cut.Instance, "_session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BackToUpload_releases_the_bundle_session()
|
||||||
|
{
|
||||||
|
// The wizard's only cancel affordance ("Back" on steps 2/3/5) routes through
|
||||||
|
// BackToUpload → ResetSessionState, which used to null the local field only.
|
||||||
|
var importer = new StoreBackedImporter();
|
||||||
|
Services.AddSingleton<IBundleImporter>(importer);
|
||||||
|
|
||||||
|
var cut = Render<TransportImportPage>();
|
||||||
|
var session = importer.OpenSession();
|
||||||
|
var content = session.DecryptedContent;
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
SetField(cut.Instance, "_session", session);
|
||||||
|
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Diff);
|
||||||
|
});
|
||||||
|
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var method = cut.Instance.GetType().GetMethod(
|
||||||
|
"BackToUpload",
|
||||||
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||||
|
method.Invoke(cut.Instance, Array.Empty<object?>());
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Null(importer.Store.Get(session.SessionId));
|
||||||
|
Assert.All(content, b => Assert.Equal(0, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_then_dispose_releases_the_session_once_without_throwing()
|
||||||
|
{
|
||||||
|
// ApplyAsync already removes the session; the wizard's own release must be
|
||||||
|
// idempotent so circuit teardown after a completed import is a silent no-op.
|
||||||
|
var importer = new StoreBackedImporter();
|
||||||
|
Services.AddSingleton<IBundleImporter>(importer);
|
||||||
|
|
||||||
|
var cut = Render<TransportImportPage>();
|
||||||
|
var session = importer.OpenSession(sourceEnv: "prod-cluster");
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
SetField(cut.Instance, "_session", session);
|
||||||
|
SetField(cut.Instance, "_resolutions",
|
||||||
|
new Dictionary<(string EntityType, string Name), ImportResolution>
|
||||||
|
{
|
||||||
|
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
|
||||||
|
});
|
||||||
|
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
|
||||||
|
SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster");
|
||||||
|
});
|
||||||
|
|
||||||
|
await cut.InvokeAsync(() => InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
|
||||||
|
Assert.Equal(
|
||||||
|
TransportImportPage.ImportWizardStep.Result,
|
||||||
|
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
|
||||||
|
Assert.Null(importer.Store.Get(session.SessionId));
|
||||||
|
|
||||||
|
// Must not throw, and must not resurrect or double-release anything.
|
||||||
|
cut.Instance.Dispose();
|
||||||
|
cut.Instance.Dispose();
|
||||||
|
|
||||||
|
Assert.Null(importer.Store.Get(session.SessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disposing_one_wizard_leaves_a_concurrent_circuits_session_open()
|
||||||
|
{
|
||||||
|
// Sessions are keyed by a GUID held only by the circuit that opened them,
|
||||||
|
// so one operator abandoning their import must not evict another's.
|
||||||
|
var importer = new StoreBackedImporter();
|
||||||
|
Services.AddSingleton<IBundleImporter>(importer);
|
||||||
|
|
||||||
|
var first = Render<TransportImportPage>();
|
||||||
|
var second = Render<TransportImportPage>();
|
||||||
|
var mine = importer.OpenSession();
|
||||||
|
var theirs = importer.OpenSession(sourceEnv: "other-cluster");
|
||||||
|
await first.InvokeAsync(() => SetField(first.Instance, "_session", mine));
|
||||||
|
await second.InvokeAsync(() => SetField(second.Instance, "_session", theirs));
|
||||||
|
|
||||||
|
first.Instance.Dispose();
|
||||||
|
|
||||||
|
Assert.Null(importer.Store.Get(mine.SessionId));
|
||||||
|
var survivor = importer.Store.Get(theirs.SessionId);
|
||||||
|
Assert.NotNull(survivor);
|
||||||
|
Assert.Contains(survivor!.DecryptedContent, b => b != 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Reflection helpers — the wizard's per-instance state is private (the
|
// Reflection helpers — the wizard's per-instance state is private (the
|
||||||
// razor partial pattern). We poke at it via reflection rather than
|
// razor partial pattern). We poke at it via reflection rather than
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ public sealed class QueryStringDrillInTests
|
|||||||
Services.AddSingleton(Substitute.For<IAuditService>());
|
Services.AddSingleton(Substitute.For<IAuditService>());
|
||||||
// M8 E2: TransportImport's Map step injects ISiteRepository.
|
// M8 E2: TransportImport's Map step injects ISiteRepository.
|
||||||
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
||||||
|
// Register row 44: the wizard reads the circuit's client IP for the
|
||||||
|
// per-IP unlock throttle. No HTTP context under bUnit — use the seam.
|
||||||
|
Services.AddSingleton(new ZB.MOM.WW.ScadaBridge.CentralUI.Auth.CircuitClientAddressProvider(
|
||||||
|
clientAddress: null));
|
||||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2734,7 +2734,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
var (_, importer) = AddBundleSubstitutes();
|
var (_, importer) = AddBundleSubstitutes();
|
||||||
|
|
||||||
var sessionId = Guid.NewGuid();
|
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
|
.Returns(new Commons.Types.Transport.BundleSession
|
||||||
{
|
{
|
||||||
SessionId = sessionId,
|
SessionId = sessionId,
|
||||||
@@ -2787,7 +2787,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
var (_, importer) = AddBundleSubstitutes();
|
var (_, importer) = AddBundleSubstitutes();
|
||||||
|
|
||||||
var sessionId = Guid.NewGuid();
|
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
|
.Returns(new Commons.Types.Transport.BundleSession
|
||||||
{
|
{
|
||||||
SessionId = sessionId,
|
SessionId = sessionId,
|
||||||
@@ -2865,7 +2865,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
|
IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
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
|
.Returns(new Commons.Types.Transport.BundleSession
|
||||||
{
|
{
|
||||||
SessionId = sessionId,
|
SessionId = sessionId,
|
||||||
@@ -3089,7 +3089,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
var (_, importer) = AddBundleSubstitutes();
|
var (_, importer) = AddBundleSubstitutes();
|
||||||
|
|
||||||
var sessionId = Guid.NewGuid();
|
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
|
.Returns(new Commons.Types.Transport.BundleSession
|
||||||
{
|
{
|
||||||
SessionId = sessionId,
|
SessionId = sessionId,
|
||||||
@@ -3126,6 +3126,99 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Assert.Contains("OpcA", success.JsonData);
|
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)
|
// Native alarm source CRUD (Task 21)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class TransportGateTests : TestKit, IDisposable
|
|||||||
var loadCalls = 0;
|
var loadCalls = 0;
|
||||||
|
|
||||||
var session = new BundleSession { SessionId = Guid.NewGuid() };
|
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(_ =>
|
.Returns(_ =>
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref loadCalls);
|
Interlocked.Increment(ref loadCalls);
|
||||||
|
|||||||
@@ -502,6 +502,269 @@ public sealed class BundleImporterLoadTests
|
|||||||
() => rig.Importer.LoadAsync(stream, passphrase: null));
|
() => rig.Importer.LoadAsync(stream, passphrase: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Register row 44 — the per-IP-per-hour unlock throttle is actually enforced.
|
||||||
|
// BundleUnlockRateLimiter was registered, injected and unit-tested but never
|
||||||
|
// invoked, so these all passed-by-doing-nothing before the wiring.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_rejects_unlock_attempts_past_the_per_ip_hourly_cap()
|
||||||
|
{
|
||||||
|
// Per-bundle strikes are effectively disabled here (100) so the throttle is
|
||||||
|
// unambiguously what stops the run.
|
||||||
|
var rig = BuildRig(opts =>
|
||||||
|
{
|
||||||
|
opts.MaxUnlockAttemptsPerIpPerHour = 3;
|
||||||
|
opts.MaxUnlockAttemptsPerSession = 100;
|
||||||
|
});
|
||||||
|
using var packed = PackEncryptedBundle(
|
||||||
|
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||||
|
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= 3; attempt++)
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var throttled = await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5"));
|
||||||
|
Assert.Equal(3, throttled.MaxAttemptsPerWindow);
|
||||||
|
Assert.Equal("10.1.1.5", throttled.ClientKey);
|
||||||
|
|
||||||
|
// Fail closed: even the CORRECT passphrase is refused while the client is
|
||||||
|
// over budget.
|
||||||
|
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "correct", clientKey: "10.1.1.5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Throttled_unlock_is_not_a_passphrase_failure_and_burns_no_strike()
|
||||||
|
{
|
||||||
|
// A throttled attempt never reaches the decryptor, so it must not consume one
|
||||||
|
// of the bundle's three strikes — otherwise the per-IP throttle would become a
|
||||||
|
// way to lock a bundle that an operator is legitimately importing.
|
||||||
|
var rig = BuildRig(opts =>
|
||||||
|
{
|
||||||
|
opts.MaxUnlockAttemptsPerIpPerHour = 1;
|
||||||
|
opts.MaxUnlockAttemptsPerSession = 3;
|
||||||
|
});
|
||||||
|
using var packed = PackEncryptedBundle(
|
||||||
|
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||||
|
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||||
|
var contentHash = rig.Serializer.ReadManifest(new MemoryStream(bundleBytes)).ContentHash;
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6"));
|
||||||
|
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6"));
|
||||||
|
|
||||||
|
// Still 1 — the denial burned no strike, so the bundle is not locked.
|
||||||
|
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unlock_throttle_is_isolated_per_client()
|
||||||
|
{
|
||||||
|
// One noisy client must not spend another operator's budget.
|
||||||
|
var rig = BuildRig(opts =>
|
||||||
|
{
|
||||||
|
opts.MaxUnlockAttemptsPerIpPerHour = 1;
|
||||||
|
opts.MaxUnlockAttemptsPerSession = 100;
|
||||||
|
});
|
||||||
|
using var packed = PackEncryptedBundle(
|
||||||
|
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||||
|
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7"));
|
||||||
|
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7"));
|
||||||
|
|
||||||
|
// A different client still has its full budget — reaches the decryptor and
|
||||||
|
// fails on the passphrase, not on the throttle.
|
||||||
|
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unattributed_callers_share_one_throttle_bucket_rather_than_bypassing_it()
|
||||||
|
{
|
||||||
|
// Conservative fallback: a caller with no IP is counted against a single
|
||||||
|
// shared bucket, so "unknown client" is a stricter budget, never none.
|
||||||
|
var rig = BuildRig(opts =>
|
||||||
|
{
|
||||||
|
opts.MaxUnlockAttemptsPerIpPerHour = 1;
|
||||||
|
opts.MaxUnlockAttemptsPerSession = 100;
|
||||||
|
});
|
||||||
|
using var packed = PackEncryptedBundle(
|
||||||
|
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||||
|
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||||
|
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
|
||||||
|
|
||||||
|
// Blank is treated the same as null — both land in the shared bucket, which
|
||||||
|
// the first attempt already emptied.
|
||||||
|
var throttled = await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: " "));
|
||||||
|
Assert.Equal(BundleImporter.UnattributedClientKey, throttled.ClientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unencrypted_bundles_do_not_consume_the_unlock_budget()
|
||||||
|
{
|
||||||
|
// The throttle guards passphrase brute-forcing; a plain bundle has no unlock
|
||||||
|
// step, so repeated loads must not be able to exhaust an operator's budget.
|
||||||
|
var rig = BuildRig(opts => opts.MaxUnlockAttemptsPerIpPerHour = 1);
|
||||||
|
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||||
|
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var session = await rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: null, clientKey: "10.1.1.9");
|
||||||
|
Assert.NotNull(session);
|
||||||
|
rig.Importer.Abandon(session.SessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_already_locked_bundle_does_not_spend_the_clients_hourly_budget()
|
||||||
|
{
|
||||||
|
// Ordering pin: the per-bundle lockout is checked BEFORE the throttle, so
|
||||||
|
// hammering a locked bundle cannot burn through the client's hourly budget
|
||||||
|
// and spill over into denying its next legitimate import.
|
||||||
|
var rig = BuildRig(opts =>
|
||||||
|
{
|
||||||
|
opts.MaxUnlockAttemptsPerSession = 1;
|
||||||
|
opts.MaxUnlockAttemptsPerIpPerHour = 2;
|
||||||
|
});
|
||||||
|
using var packed = PackEncryptedBundle(
|
||||||
|
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||||
|
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||||
|
|
||||||
|
// One wrong attempt locks the bundle (cap 1) and spends 1 of 2 IP budget.
|
||||||
|
await Assert.ThrowsAsync<BundleLockedException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10"));
|
||||||
|
|
||||||
|
// Three more attempts on the locked bundle stay BundleLocked — none of them
|
||||||
|
// register against the rate limiter.
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<BundleLockedException>(
|
||||||
|
() => rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DIFFERENT bundle from the same client still has budget left.
|
||||||
|
using var other = PackEncryptedBundle(
|
||||||
|
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct2");
|
||||||
|
var otherSession = await rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(((MemoryStream)other).ToArray()),
|
||||||
|
passphrase: "correct2", clientKey: "10.1.1.10");
|
||||||
|
Assert.NotNull(otherSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Register row 43 — abandoned-session release.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abandon_releases_the_session_slot_and_zeroes_its_decrypted_content()
|
||||||
|
{
|
||||||
|
// Row 43: only ApplyAsync used to release a session, so any caller that
|
||||||
|
// loaded a bundle and walked away held one of MaxConcurrentImportSessions
|
||||||
|
// (plus the decrypted plaintext) for the full TTL.
|
||||||
|
var rig = BuildRig();
|
||||||
|
using var packed = PackEncryptedBundle(
|
||||||
|
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct");
|
||||||
|
var session = await rig.Importer.LoadAsync(
|
||||||
|
new MemoryStream(((MemoryStream)packed).ToArray()), passphrase: "correct");
|
||||||
|
|
||||||
|
var decrypted = session.DecryptedContent;
|
||||||
|
Assert.NotEmpty(decrypted);
|
||||||
|
Assert.Contains(decrypted, b => b != 0);
|
||||||
|
Assert.NotNull(rig.SessionStore.Get(session.SessionId));
|
||||||
|
|
||||||
|
rig.Importer.Abandon(session.SessionId);
|
||||||
|
|
||||||
|
Assert.Null(rig.SessionStore.Get(session.SessionId));
|
||||||
|
// Zeroed in place, so even a caller still holding the reference (the Razor
|
||||||
|
// page keeps one for the Result step) can no longer read the plaintext.
|
||||||
|
Assert.All(decrypted, b => Assert.Equal(0, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abandon_is_idempotent_and_tolerates_unknown_ids()
|
||||||
|
{
|
||||||
|
// The Razor page calls Abandon from Dispose even after a successful Apply
|
||||||
|
// already released the same id; a double release must be a silent no-op.
|
||||||
|
var rig = BuildRig();
|
||||||
|
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||||
|
var session = await rig.Importer.LoadAsync(packed, passphrase: null);
|
||||||
|
|
||||||
|
rig.Importer.Abandon(session.SessionId);
|
||||||
|
rig.Importer.Abandon(session.SessionId);
|
||||||
|
rig.Importer.Abandon(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.Null(rig.SessionStore.Get(session.SessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abandon_does_not_release_another_callers_session()
|
||||||
|
{
|
||||||
|
// Sessions are keyed by a random GUID handed only to their opener, so one
|
||||||
|
// operator abandoning their wizard must not evict a concurrent import.
|
||||||
|
var rig = BuildRig();
|
||||||
|
using var packedA = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||||
|
using var packedB = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
|
||||||
|
var a = await rig.Importer.LoadAsync(packedA, passphrase: null);
|
||||||
|
var b = await rig.Importer.LoadAsync(packedB, passphrase: null);
|
||||||
|
|
||||||
|
rig.Importer.Abandon(a.SessionId);
|
||||||
|
|
||||||
|
Assert.Null(rig.SessionStore.Get(a.SessionId));
|
||||||
|
var survivor = rig.SessionStore.Get(b.SessionId);
|
||||||
|
Assert.NotNull(survivor);
|
||||||
|
Assert.NotEmpty(survivor!.DecryptedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abandon_frees_a_concurrent_import_session_slot()
|
||||||
|
{
|
||||||
|
// The availability half of row 43: abandoned sessions used to exhaust the
|
||||||
|
// cap and lock out every import on the node until the TTL expired.
|
||||||
|
var rig = BuildRig(opts => opts.MaxConcurrentImportSessions = 2);
|
||||||
|
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||||
|
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||||
|
|
||||||
|
var first = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
|
||||||
|
await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
|
||||||
|
|
||||||
|
var full = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null));
|
||||||
|
Assert.Contains("Too many concurrent import sessions", full.Message);
|
||||||
|
|
||||||
|
rig.Importer.Abandon(first.SessionId);
|
||||||
|
|
||||||
|
var third = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
|
||||||
|
Assert.NotNull(rig.SessionStore.Get(third.SessionId));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a zip directly so the test can write a manifest whose
|
/// Builds a zip directly so the test can write a manifest whose
|
||||||
/// <c>ContentHash</c> or <c>BundleFormatVersion</c> intentionally
|
/// <c>ContentHash</c> or <c>BundleFormatVersion</c> intentionally
|
||||||
|
|||||||
Reference in New Issue
Block a user