Files
ScadaBridge/tests/ScadaLink.Transport.Tests/Import/BundleImporterLoadTests.cs
T
Joseph Doherty 5d2386cc9d 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.
2026-05-28 04:14:07 -04:00

529 lines
24 KiB
C#

using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.TemplateEngine.Validation;
using ScadaLink.Transport.Encryption;
using ScadaLink.Transport.Import;
using ScadaLink.Transport.Serialization;
namespace ScadaLink.Transport.Tests.Import;
/// <summary>
/// Unit tests for <see cref="BundleImporter.LoadAsync"/>. Uses the real
/// <see cref="BundleSerializer"/>, <see cref="ManifestValidator"/>,
/// <see cref="BundleSecretEncryptor"/>, <see cref="EntitySerializer"/> and
/// <see cref="BundleSessionStore"/> — they're stateless / in-memory and easier
/// to drive than mocks. Repositories + audit + DbContext are mocked because
/// LoadAsync does not exercise them (they're injected so the constructor stays
/// stable across T15/T16/T17).
/// </summary>
public sealed class BundleImporterLoadTests
{
private static readonly JsonSerializerOptions BundleJsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
private static BundleContentDto EmptyContent() => new(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private static BundleContentDto SmallContent() => new(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: new[]
{
new TemplateDto(
Name: "Pump",
FolderName: null,
BaseTemplateName: null,
Description: "the one and only",
Attributes: Array.Empty<TemplateAttributeDto>(),
Alarms: Array.Empty<TemplateAlarmDto>(),
Scripts: Array.Empty<TemplateScriptDto>(),
Compositions: Array.Empty<TemplateCompositionDto>()),
},
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed record TestRig(
BundleImporter Importer,
BundleSerializer Serializer,
ManifestBuilder ManifestBuilder,
BundleSecretEncryptor Encryptor,
BundleSessionStore SessionStore,
TransportOptions Options);
private static TestRig BuildRig(Action<TransportOptions>? configure = null)
{
var opts = new TransportOptions();
configure?.Invoke(opts);
var iOpts = Options.Create(opts);
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var serializer = new BundleSerializer();
var validator = new ManifestValidator();
var encryptor = new BundleSecretEncryptor();
var entitySerializer = new EntitySerializer();
var manifestBuilder = new ManifestBuilder();
var store = new BundleSessionStore(iOpts, clock);
var importer = new BundleImporter(
bundleSerializer: serializer,
manifestValidator: validator,
encryptor: encryptor,
entitySerializer: entitySerializer,
sessionStore: store,
options: iOpts,
timeProvider: clock,
templateRepo: Substitute.For<ITemplateEngineRepository>(),
externalRepo: Substitute.For<IExternalSystemRepository>(),
notificationRepo: Substitute.For<INotificationRepository>(),
inboundApiRepo: Substitute.For<IInboundApiRepository>(),
auditService: Substitute.For<IAuditService>(),
correlationContext: Substitute.For<IAuditCorrelationContext>(),
// LoadAsync never touches the DbContext — Preview/Apply do. Build
// a no-provider DbContext so the importer's null check passes;
// the in-memory provider isn't worth pulling in for unit tests.
dbContext: new ScadaLinkDbContext(
new DbContextOptionsBuilder<ScadaLinkDbContext>().Options),
semanticValidator: new SemanticValidator());
return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts);
}
private static Stream PackPlainBundle(BundleSerializer serializer, ManifestBuilder builder, BundleContentDto content)
{
var contentBytes = serializer.SerializeContentBytes(content);
var manifest = builder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase: null, encryptor: null);
}
private static Stream PackEncryptedBundle(
BundleSerializer serializer,
ManifestBuilder builder,
BundleSecretEncryptor encryptor,
BundleContentDto content,
string passphrase)
{
var contentBytes = serializer.SerializeContentBytes(content);
// Pack re-stamps salt/iv/hash from the ciphertext it actually writes,
// so the seed values here are placeholders.
var seed = new EncryptionMetadata("AES-256-GCM", "PBKDF2-SHA256", 600_000, string.Empty, string.Empty);
var manifest = builder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
encryption: seed,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase, encryptor);
}
[Fact]
public async Task LoadAsync_returns_session_for_unencrypted_bundle()
{
var rig = BuildRig();
var content = SmallContent();
var stream = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, content);
var session = await rig.Importer.LoadAsync(stream, passphrase: null);
Assert.NotNull(session);
Assert.NotEqual(Guid.Empty, session.SessionId);
Assert.Equal("dev", session.Manifest.SourceEnvironment);
Assert.Equal("alice", session.Manifest.ExportedBy);
Assert.Null(session.Manifest.Encryption);
Assert.NotNull(rig.SessionStore.Get(session.SessionId));
}
[Fact]
public async Task LoadAsync_returns_session_for_encrypted_bundle_with_correct_passphrase()
{
var rig = BuildRig();
var content = SmallContent();
var stream = PackEncryptedBundle(rig.Serializer, rig.ManifestBuilder, rig.Encryptor, content, "secret123");
var session = await rig.Importer.LoadAsync(stream, passphrase: "secret123");
Assert.NotNull(session);
Assert.NotNull(session.Manifest.Encryption);
Assert.NotEmpty(session.DecryptedContent);
// The decrypted payload must round-trip back to the original DTO so the
// PreviewAsync phase can deserialize it directly from the session.
var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(
session.DecryptedContent, BundleJsonOptions);
Assert.NotNull(roundTripped);
Assert.Single(roundTripped!.Templates);
Assert.Equal("Pump", roundTripped.Templates[0].Name);
}
[Fact]
public async Task LoadAsync_throws_when_passphrase_wrong()
{
var rig = BuildRig();
var stream = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
// AES-GCM raises AuthenticationTagMismatchException, a CryptographicException
// subclass on .NET 10 — ThrowsAny is the right match.
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(stream, passphrase: "wrong"));
}
[Fact]
public async Task LoadAsync_locks_bundle_after_three_wrong_passphrases_even_across_callers()
{
// T-003: the lockout is server-side and keyed by ContentHash, so replaying
// the SAME bundle bytes from a second caller (different stream, different
// session) must hit the same counter. After MaxUnlockAttemptsPerSession (3)
// failures the importer throws BundleLockedException, not another
// CryptographicException — and the lock survives a fresh LoadAsync from a
// pristine caller that has no idea about the prior attempts.
var rig = BuildRig();
using var packed = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
var bundleBytes = ((MemoryStream)packed).ToArray();
// First two wrong-passphrase attempts surface as CryptographicException.
for (var attempt = 1; attempt <= 2; attempt++)
{
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
}
// Third wrong-passphrase attempt crosses the threshold and surfaces as
// BundleLockedException — even though this is a fresh stream / a caller
// that never saw the earlier failures.
var locked = await Assert.ThrowsAsync<BundleLockedException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
Assert.Equal(3, locked.FailedAttempts);
// Fourth attempt — even with the CORRECT passphrase — is still locked,
// because the lockout sticks until the TTL expires or the bundle is
// re-exported with a new content hash.
await Assert.ThrowsAsync<BundleLockedException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct"));
}
[Fact]
public async Task LoadAsync_rejects_bundle_with_too_many_entries()
{
// T-006: a malicious bundle could pad the archive with arbitrary entries to
// exhaust per-entry handles or to slip an unexpected payload past the
// serializer. The envelope check rejects any archive whose entry count
// exceeds MaxBundleEntryCount BEFORE any decompression.
var rig = BuildRig(opts => opts.MaxBundleEntryCount = 2);
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
var bytes = ((MemoryStream)packed).ToArray();
var paddedBytes = AppendExtraZipEntry(bytes, "extra.bin", new byte[8]);
await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(new MemoryStream(paddedBytes), passphrase: null));
}
[Fact]
public async Task LoadAsync_rejects_bundle_with_oversized_entry()
{
// T-006: caller-declared decompressed Length above the configured cap is
// a hostile bundle; reject without decompressing.
var rig = BuildRig(opts => opts.MaxBundleEntryDecompressedMb = 1);
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
var bytes = ((MemoryStream)packed).ToArray();
// Replace content.json with a 2 MB entry of compressible zeros — uncompressed Length > cap.
var bigPayload = new byte[2 * 1024 * 1024];
var oversizedBytes = ReplaceZipEntry(bytes, "content.json", bigPayload);
await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(new MemoryStream(oversizedBytes), passphrase: null));
}
[Fact]
public async Task LoadAsync_rejects_bundle_whose_entry_exceeds_compression_ratio()
{
// T-006: defence-in-depth — even if Length is within the per-entry cap, an
// extreme compression ratio is a hallmark of a decompression bomb and is
// rejected outright.
var rig = BuildRig(opts =>
{
opts.MaxBundleEntryDecompressedMb = 100;
opts.MaxBundleEntryCompressionRatio = 10;
});
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
var bytes = ((MemoryStream)packed).ToArray();
// 1 MB of zeros compresses extremely well (>100x ratio) — well over the
// configured 10x cap.
var compressible = new byte[1024 * 1024];
var bombBytes = ReplaceZipEntry(bytes, "content.json", compressible);
await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(new MemoryStream(bombBytes), passphrase: null));
}
/// <summary>
/// T-006 helper: rewrites an existing zip to add a fresh entry alongside the
/// originals. Used by the "too many entries" test.
/// </summary>
private static byte[] AppendExtraZipEntry(byte[] zipBytes, string newEntryName, byte[] payload)
{
var output = new MemoryStream();
using (var src = new MemoryStream(zipBytes))
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in srcZip.Entries)
{
var dst = dstZip.CreateEntry(entry.FullName);
using var inStream = entry.Open();
using var outStream = dst.Open();
inStream.CopyTo(outStream);
}
var extra = dstZip.CreateEntry(newEntryName);
using (var s = extra.Open()) { s.Write(payload); }
}
return output.ToArray();
}
/// <summary>
/// T-006 helper: rewrites an existing zip, replacing one entry's bytes with
/// the supplied payload while preserving every other entry verbatim.
/// </summary>
private static byte[] ReplaceZipEntry(byte[] zipBytes, string entryToReplace, byte[] newPayload)
{
var output = new MemoryStream();
using (var src = new MemoryStream(zipBytes))
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in srcZip.Entries)
{
var dst = dstZip.CreateEntry(entry.FullName);
using var outStream = dst.Open();
if (entry.FullName == entryToReplace)
{
outStream.Write(newPayload);
}
else
{
using var inStream = entry.Open();
inStream.CopyTo(outStream);
}
}
}
return output.ToArray();
}
[Fact]
public async Task LoadAsync_rejects_bundle_with_tampered_manifest_field_even_with_correct_passphrase()
{
// T-005: a stolen bundle whose plaintext manifest fields (SourceEnvironment,
// ExportedBy, …) have been edited must fail decryption with a tag mismatch.
// Without AAD an attacker could rewrite the SourceEnvironment label and slip
// past the Step-4 typo-resistant confirmation gate. We tamper the field by
// re-packing the manifest (with everything else, including the original
// ciphertext, unchanged) into a fresh ZIP and verify decrypt fails.
var rig = BuildRig();
using var packed = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
var bytes = ((MemoryStream)packed).ToArray();
// Read manifest + ciphertext from the legit bundle, mutate SourceEnvironment,
// and re-pack with the SAME ciphertext bytes — the cipher is fine, only the
// plaintext manifest is changed.
BundleManifest originalManifest;
byte[] cipherBytes;
using (var src = new MemoryStream(bytes))
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
{
using var ms = new MemoryStream();
srcZip.GetEntry("manifest.json")!.Open().CopyTo(ms);
originalManifest = JsonSerializer.Deserialize<BundleManifest>(ms.ToArray(), BundleJsonOptions)!;
using var ctMs = new MemoryStream();
srcZip.GetEntry("content.enc")!.Open().CopyTo(ctMs);
cipherBytes = ctMs.ToArray();
}
var tampered = originalManifest with { SourceEnvironment = "prod-spoofed" };
var tamperedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(tampered, BundleJsonOptions);
var tamperedZipBytes = new MemoryStream();
using (var outZip = new ZipArchive(tamperedZipBytes, ZipArchiveMode.Create, leaveOpen: true))
{
var mEntry = outZip.CreateEntry("manifest.json");
using (var s = mEntry.Open()) { s.Write(tamperedManifestBytes); }
var cEntry = outZip.CreateEntry("content.enc");
using (var s = cEntry.Open()) { s.Write(cipherBytes); }
}
tamperedZipBytes.Position = 0;
// ContentHash check is the FIRST thing that catches the tamper here because
// ContentHash is sealed against the cipher bytes and the cipher is unchanged
// BUT the manifest's plaintext fields are mutated — wait, ContentHash is
// against the cipher which we kept, so it still matches. The defence is
// the AES-GCM AAD: the tag check fails because AAD differs.
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(tamperedZipBytes, passphrase: "correct"));
}
[Fact]
public async Task LoadAsync_resets_unlock_counter_on_correct_passphrase()
{
// T-003: a legitimate operator who typos once or twice before getting it
// right must not be penalised on the next bundle. A successful LoadAsync
// clears the per-bundle counter.
var rig = BuildRig();
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"));
var session = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct");
Assert.NotNull(session);
// Counter cleared — a subsequent wrong-passphrase attempt starts from 0
// and surfaces as CryptographicException, not BundleLockedException.
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(session.Manifest.ContentHash));
}
[Fact]
public async Task LoadAsync_throws_NotSupportedException_when_bundleFormatVersion_unsupported()
{
var rig = BuildRig();
// Synthesize a zip by hand whose manifest carries an unsupported format
// version. The validator looks at BundleFormatVersion first thing after
// null-checks, so the content hash doesn't need to be correct for this
// path — we just need a structurally valid manifest record.
var content = EmptyContent();
var contentBytes = rig.Serializer.SerializeContentBytes(content);
var forwardManifest = new BundleManifest(
BundleFormatVersion: 999,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: "dev",
ExportedBy: "alice",
ScadaLinkVersion: "1.0.0",
ContentHash: "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(),
Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>());
var bundleStream = HandCraftZip(forwardManifest, contentBytes, encrypted: false);
await Assert.ThrowsAsync<NotSupportedException>(
() => rig.Importer.LoadAsync(bundleStream, passphrase: null));
}
[Fact]
public async Task LoadAsync_throws_InvalidDataException_when_content_hash_mismatch()
{
var rig = BuildRig();
// Build a normal bundle, then corrupt content.json's bytes after the
// manifest is stamped — the manifest still references the original hash.
var content = SmallContent();
var originalContentBytes = rig.Serializer.SerializeContentBytes(content);
var manifest = rig.ManifestBuilder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: originalContentBytes);
// Corrupt the bytes so the validator's recomputed hash diverges from
// the manifest's frozen hash.
var corrupted = (byte[])originalContentBytes.Clone();
corrupted[0] ^= 0xFF;
var bundleStream = HandCraftZip(manifest, corrupted, encrypted: false);
var ex = await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(bundleStream, passphrase: null));
Assert.Contains("hash", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task LoadAsync_throws_InvalidOperationException_when_bundle_exceeds_size_cap()
{
// MaxBundleSizeMb is an int; the smallest positive value is 1 MB. Pack
// a normal bundle and bump it past 1 MB by padding with a long
// description, then cap the limit to 0 — the comparison is `> maxBytes`
// so any positive byte count exceeds a 0 MB cap.
var rig = BuildRig(opts => opts.MaxBundleSizeMb = 0);
var stream = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
await Assert.ThrowsAsync<InvalidOperationException>(
() => rig.Importer.LoadAsync(stream, passphrase: null));
}
/// <summary>
/// Builds a zip directly so the test can write a manifest whose
/// <c>ContentHash</c> or <c>BundleFormatVersion</c> intentionally
/// disagrees with the content bytes — paths the high-level
/// <see cref="BundleSerializer.Pack"/> won't produce because it always
/// re-stamps the hash itself.
/// </summary>
private static Stream HandCraftZip(BundleManifest manifest, byte[] contentBytes, bool encrypted)
{
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, BundleJsonOptions);
var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
WriteEntry(archive, "manifest.json", manifestBytes);
WriteEntry(archive, encrypted ? "content.enc" : "content.json", contentBytes);
}
ms.Position = 0;
return ms;
}
private static void WriteEntry(ZipArchive archive, string name, byte[] payload)
{
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
using var es = entry.Open();
es.Write(payload, 0, payload.Length);
}
}