fix(validation): close Theme 3 — 11 input-validation / unbounded-input findings
Each finding is a focused validation guard or upper bound at a trust boundary.
Highlights:
- Commons-015: EncryptionMetadata ctor now validates Algorithm (AES-256-GCM
only), Kdf (PBKDF2-SHA256 only), Iterations ([100k, 10M]), non-null Salt/IV.
- Transport-004: new BundleUnlockRateLimiter (sliding-window, per-key,
singleton) wired into BundleImporter.LoadAsync; over-budget callers see
BundleUnlockRateLimitedException. Per-bundle 3-strike + per-window cap.
- ESG-022: ExternalSystemClient.InvokeHttpAsync allow-lists the documented
GET/POST/PUT/PATCH/DELETE set (case-insensitive); unknown verbs throw.
- SEL-015: SiteEventLogger queue now bounded (10k cap, DropOldest); dropped
events fault their Task and increment FailedWriteCount so the drop is
observable instead of an unbounded memory growth.
- SEL-017: EventLogQueryService clamps caller-supplied PageSize to a new
MaxQueryPageSize cap (default 500) so int.MaxValue can't OOM the host.
- SEL-020: LogEventAsync rejects severities outside {Info, Warning, Error}
(matches SQLite BINARY-collation query filter).
- InboundAPI-020: ContentType "json" check now case-insensitive
(application/JSON no longer slips through as not-json).
- InboundAPI-024: _knownBadMethods capped at 1000 entries (drops new entries
once full); per-request DB lookup remains the correctness path.
- SR-025: HandleSetStaticAttribute validates the attribute name against the
deployed config; unknown names now return Success=false instead of
leaking orphan override rows into the SQLite store.
- TE-021: MoveTemplateAsync runs the sibling-name-collision check at the
destination, mirroring TemplateFolderService.MoveFolderAsync.
- TE-022: LockEnforcer's once-locked-stays-locked rule now also covers
LockedInDerived (was previously only IsLocked).
New regression tests across 8 test projects (EncryptionMetadata, rate
limiter, ESG client allow-list, SEL bounded channel / PageSize clamp /
severity validation, InboundAPI ContentType + bad-methods cap, SiteRT
unknown-attribute, TemplateEngine MoveTemplate + LockedInDerived).
Build clean; affected suites all green. README regenerated: 93 open (was 104).
Note: a separate manual re-run was needed for the SiteEventLogging hunk
because its initial subagent's source edits never landed on disk despite
reporting success (file-collision-style failure mode).
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.Transport.Encryption;
|
||||
|
||||
namespace ScadaLink.Transport.Tests.Encryption;
|
||||
|
||||
public sealed class BundleSecretEncryptorTests
|
||||
{
|
||||
private const int TestIterations = 10_000; // Lower than production for test speed.
|
||||
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum);
|
||||
// the production value is 600_000. Using the floor keeps the test fast while
|
||||
// remaining a valid EncryptionMetadata.Iterations.
|
||||
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
|
||||
|
||||
[Fact]
|
||||
public void Encrypt_then_Decrypt_roundtrips_arbitrary_bytes()
|
||||
|
||||
@@ -102,6 +102,10 @@ public sealed class BundleImporterLoadTests
|
||||
encryptor: encryptor,
|
||||
entitySerializer: entitySerializer,
|
||||
sessionStore: store,
|
||||
// T-004: the unlock rate limiter shares the test clock so its trailing-hour
|
||||
// window pruning is deterministic. The window itself is the production
|
||||
// default (1 hour).
|
||||
unlockRateLimiter: new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow),
|
||||
options: iOpts,
|
||||
timeProvider: clock,
|
||||
templateRepo: Substitute.For<ITemplateEngineRepository>(),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using ScadaLink.Transport.Import;
|
||||
|
||||
namespace ScadaLink.Transport.Tests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-004: <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
|
||||
/// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The
|
||||
/// limiter accepts any opaque caller key (typically a remote IP); these tests use
|
||||
/// IP-style strings to mirror the documented intent.
|
||||
/// </summary>
|
||||
public sealed class BundleUnlockRateLimiterTests
|
||||
{
|
||||
private sealed class TestClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
public TestClock(DateTimeOffset start) { _now = start; }
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
public void Advance(TimeSpan delta) { _now += delta; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_UnderLimit_ReturnsTrue()
|
||||
{
|
||||
// The first N attempts at the same key are permitted; the trailing-hour
|
||||
// count tracks them.
|
||||
var clock = new TestClock(DateTimeOffset.UtcNow);
|
||||
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
|
||||
|
||||
for (var i = 1; i <= 10; i++)
|
||||
{
|
||||
Assert.True(
|
||||
limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10),
|
||||
$"Attempt {i} should be allowed (under the cap).");
|
||||
}
|
||||
|
||||
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_AtLimit_RejectsNextAttempt()
|
||||
{
|
||||
// N attempts allowed, attempt N+1 rejected — the headline contract.
|
||||
var clock = new TestClock(DateTimeOffset.UtcNow);
|
||||
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
// 11th attempt within the hour exceeds the cap.
|
||||
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
|
||||
// Subsequent attempts also rejected — the limiter does NOT silently let a
|
||||
// 12th, 13th, ... attempt through (no leak past the cap).
|
||||
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
|
||||
// And the recorded count never exceeds the cap (rejected attempts are not
|
||||
// appended to the trailing-hour queue).
|
||||
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_EntriesExpireAfterWindow()
|
||||
{
|
||||
// Once the trailing-hour window rolls past every recorded attempt the key
|
||||
// is fully reset — a legitimate operator returning later is not penalised.
|
||||
var clock = new TestClock(DateTimeOffset.UtcNow);
|
||||
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
|
||||
// Roll just past the window's end. Every recorded timestamp is now
|
||||
// strictly older than (now - window) and must be pruned.
|
||||
clock.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.Equal(0, limiter.GetAttemptCount("10.0.0.1"));
|
||||
// A fresh full budget is available.
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly()
|
||||
{
|
||||
// Sliding window — when only some of the recorded entries have aged out,
|
||||
// exactly that many slots are released.
|
||||
var clock = new TestClock(DateTimeOffset.UtcNow);
|
||||
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
|
||||
|
||||
// Five attempts at t=0.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
// 30 minutes later, five more — saturates the budget.
|
||||
clock.Advance(TimeSpan.FromMinutes(30));
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
|
||||
// Roll just past the first batch's window. Only those five entries expire;
|
||||
// the second batch (recorded at t=30) is still within window from t=61.
|
||||
clock.Advance(TimeSpan.FromMinutes(31));
|
||||
|
||||
Assert.Equal(5, limiter.GetAttemptCount("10.0.0.1"));
|
||||
// Five fresh slots are available.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRegisterAttempt_PerKeyIsolation()
|
||||
{
|
||||
// The cap is per key — saturating one IP does not affect another.
|
||||
var clock = new TestClock(DateTimeOffset.UtcNow);
|
||||
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
}
|
||||
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
|
||||
|
||||
// A different IP has its own untouched budget.
|
||||
Assert.True(limiter.TryRegisterAttempt("10.0.0.2", maxAttemptsPerWindow: 10));
|
||||
Assert.Equal(1, limiter.GetAttemptCount("10.0.0.2"));
|
||||
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void TryRegisterAttempt_BlankKey_Throws(string? key)
|
||||
{
|
||||
var limiter = new BundleUnlockRateLimiter();
|
||||
Assert.ThrowsAny<ArgumentException>(
|
||||
() => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void TryRegisterAttempt_NonPositiveLimit_Throws(int limit)
|
||||
{
|
||||
var limiter = new BundleUnlockRateLimiter();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: limit));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ namespace ScadaLink.Transport.Tests.Serialization;
|
||||
|
||||
public sealed class BundleSerializerTests
|
||||
{
|
||||
private const int TestIterations = 10_000;
|
||||
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum).
|
||||
// Using the floor keeps the suite fast while passing EncryptionMetadata
|
||||
// validation.
|
||||
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
|
||||
|
||||
private static BundleContentDto SampleContent() => new(
|
||||
TemplateFolders: new[] { new TemplateFolderDto("Root", ParentName: null, SortOrder: 0) },
|
||||
|
||||
Reference in New Issue
Block a user