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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user