fix(transport): close bundle security + plaintext-retention gaps (4 findings)
T-003: move the unlock lockout server-side. The 3-strike counter used to live in the Razor page only — a second tab / CLI caller could re-upload the same bytes and grind PBKDF2 indefinitely. The counter now lives in IBundleSessionStore, keyed by ContentHash, so retries against identical bundle bytes are throttled regardless of client. BundleLockedException surfaces the new typed error path. T-005: bind the manifest's non-derivative fields into AES-GCM AAD. A SHA-256 of the manifest (with ContentHash + Encryption normalised to sentinels) is now passed to AesGcm.Encrypt / .Decrypt, so a tampered SourceEnvironment / ExportedBy / CreatedAtUtc on a stolen bundle yields an authentication-tag mismatch instead of slipping past the Step-4 typo-resistant confirmation gate. T-006: cap zip entry count, decompressed length, and compression ratio in LoadAsync's envelope validator BEFORE any payload is decompressed, using ZipArchiveEntry.Length / .CompressedLength. New TransportOptions fields default to 4 entries / 200 MB / 50x ratio. T-007: clear decrypted plaintext on the ApplyAsync failure path and zero the buffer on success before removing the session, so a 100 MB DecryptedContent doesn't sit in memory for the 30-min TTL after a failed apply. A BundleSessionEvictionService BackgroundService now also drives EvictExpired periodically so abandoned sessions clear without needing a fresh Get() call to trigger lazy eviction. Also resolves NO-010 — the misleading "writer never throws" XML doc was the same code+comment my prior NO-004 await-the-writer fix already rewrote.
This commit is contained in:
@@ -310,6 +310,40 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.Equal(0, await ctx.Templates.CountAsync());
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
|
||||
}
|
||||
|
||||
// T-007: a failed apply used to keep the BundleSession (and its decrypted
|
||||
// secrets) in the in-memory store for the full 30-minute TTL. The session
|
||||
// must now be removed immediately so the plaintext is released.
|
||||
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
||||
Assert.Null(sessionStore.Get(sessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_removes_session_on_success_path_too()
|
||||
{
|
||||
// T-007: companion to the failed-apply test — the success path must also
|
||||
// remove the session (it was already doing so before T-007, but the new
|
||||
// test asserts the contract explicitly so a future refactor cannot
|
||||
// accidentally leave plaintext in the store).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("PumpForT007") { Description = "fresh" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "PumpForT007", ResolutionAction.Add, null) },
|
||||
user: "alice");
|
||||
}
|
||||
|
||||
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
||||
Assert.Null(sessionStore.Get(sessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user