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:
Joseph Doherty
2026-05-28 06:58:25 -04:00
parent 344379a40a
commit 819f1b4665
35 changed files with 1457 additions and 73 deletions
+28 -1
View File
@@ -8,7 +8,11 @@ namespace ScadaLink.TemplateEngine;
/// Locking rules:
/// - Locked members cannot be overridden downstream (child templates or compositions).
/// - Any level can lock an unlocked member (intermediate locking).
/// - Once locked, a member stays locked — it cannot be unlocked downstream.
/// - Once locked, a member stays locked — neither <see cref="TemplateAttribute.IsLocked"/>
/// nor <see cref="TemplateAttribute.LockedInDerived"/> may be cleared after it has
/// been set. The same one-way ratchet applies to alarms and scripts. This pins
/// the design intent so a base template cannot retroactively re-allow derived
/// overrides that were previously blocked (TemplateEngine-022).
///
/// Override granularity:
/// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed.
@@ -115,4 +119,27 @@ public static class LockEnforcer
return null;
}
/// <summary>
/// Validates that a <see cref="TemplateAttribute.LockedInDerived"/> (or alarm/script)
/// flag change is legal. <c>LockedInDerived</c> follows the same one-way ratchet
/// as <c>IsLocked</c> — once set on a base template, it cannot be cleared,
/// otherwise derived templates that were previously blocked from overriding the
/// field would become retroactively allowed (TemplateEngine-022).
/// </summary>
/// <param name="originalLockedInDerived">Current <c>LockedInDerived</c> state.</param>
/// <param name="proposedLockedInDerived">Proposed <c>LockedInDerived</c> state.</param>
/// <param name="memberName">Name of the member being changed, for error messages.</param>
public static string? ValidateLockedInDerivedChange(
bool originalLockedInDerived,
bool proposedLockedInDerived,
string memberName)
{
if (originalLockedInDerived && !proposedLockedInDerived)
{
return $"Member '{memberName}' is locked-in-derived and that lock cannot be cleared.";
}
return null;
}
}
@@ -187,6 +187,31 @@ public class TemplateService
return Result<Template>.Failure($"Target folder with ID {newFolderId.Value} not found.");
}
// No-op move — skip the collision check (a template moving to its own
// folder cannot collide with itself).
if (template.FolderId != newFolderId)
{
// Sibling-name uniqueness at the destination (TemplateEngine-021),
// mirroring TemplateFolderService.MoveFolderAsync. A template move
// changes only FolderId, so there is no inheritance- or
// composition-graph cycle to detect (templates have no folder-
// children navigation; ParentTemplateId is untouched here). The
// only invariant the move can break is two templates sharing a
// (FolderId, Name) at the destination, which the design's
// naming-collisions-are-design-time-errors rule forbids.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var collision = allTemplates.FirstOrDefault(t =>
t.Id != templateId &&
t.FolderId == newFolderId &&
string.Equals(t.Name, template.Name, StringComparison.OrdinalIgnoreCase));
if (collision != null)
{
var location = newFolderId.HasValue ? "the target folder" : "the root";
return Result<Template>.Failure(
$"A template named '{template.Name}' already exists in {location}.");
}
}
template.FolderId = newFolderId;
await _repository.UpdateTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);
@@ -304,6 +329,19 @@ public class TemplateService
if (lockError != null)
return Result<TemplateAttribute>.Failure(lockError);
// LockedInDerived is a one-way ratchet on base templates: once a base
// marks an attribute LockedInDerived it cannot be cleared, otherwise
// derived overrides that were previously blocked would become
// retroactively legal (TemplateEngine-022). Only meaningful on base
// templates — derived rows never carry an authoritative LockedInDerived.
if (template?.IsDerived != true)
{
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
if (lockedInDerivedError != null)
return Result<TemplateAttribute>.Failure(lockedInDerivedError);
}
// Validate fixed-field granularity. DataType and DataSourceReference are
// fixed by the defining level for every attribute — locked or not — so
// the error is always honoured (a locked attribute is already rejected
@@ -459,6 +497,15 @@ public class TemplateService
}
}
// One-way LockedInDerived ratchet on base templates (TemplateEngine-022).
if (template?.IsDerived != true)
{
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
if (lockedInDerivedError != null)
return Result<TemplateAlarm>.Failure(lockedInDerivedError);
}
// Validate fixed fields
var overrideError = LockEnforcer.ValidateAlarmOverride(existing, proposed);
if (overrideError != null)
@@ -582,7 +629,8 @@ public class TemplateService
if (lockError != null)
return Result<TemplateScript>.Failure(lockError);
// Check parent lock
// Check parent lock; the LockedInDerived ratchet is enforced after we
// know whether the owning template is derived.
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
@@ -604,6 +652,15 @@ public class TemplateService
}
}
// One-way LockedInDerived ratchet on base templates (TemplateEngine-022).
if (template?.IsDerived != true)
{
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
if (lockedInDerivedError != null)
return Result<TemplateScript>.Failure(lockedInDerivedError);
}
// Validate fixed fields
var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
if (overrideError != null)