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
@@ -1,8 +1,93 @@
namespace ScadaLink.Commons.Types.Transport;
public sealed record EncryptionMetadata(
string Algorithm, // "AES-256-GCM"
string Kdf, // "PBKDF2-SHA256"
int Iterations,
string SaltB64,
string IvB64);
/// <summary>
/// AES-GCM encryption envelope metadata for a bundle's content payload. Carried on
/// the bundle manifest (plaintext) so the importer can derive the per-bundle key and
/// initialise the cipher without prior knowledge of the passphrase.
/// <para>
/// Commons-015: invariants are enforced in the constructor so a malformed envelope
/// (unknown algorithm, unsupported KDF, weak iteration count, null salt/IV) is
/// rejected at the type boundary rather than failing inside
/// <see cref="System.Security.Cryptography.AesGcm"/> with a misleading exception.
/// </para>
/// </summary>
public sealed record EncryptionMetadata
{
/// <summary>The only AES symmetric algorithm the bundle format supports.</summary>
public const string SupportedAlgorithm = "AES-256-GCM";
/// <summary>The only key-derivation function the bundle format supports.</summary>
public const string SupportedKdf = "PBKDF2-SHA256";
/// <summary>
/// PBKDF2 iteration-count floor — OWASP's documented minimum. The Transport design
/// doc specifies <c>600_000</c> as the production value; this constant is the hard
/// reject threshold below which the envelope is treated as malformed.
/// </summary>
public const int MinPbkdf2Iterations = 100_000;
/// <summary>
/// PBKDF2 iteration-count ceiling — guards against a hostile bundle declaring an
/// absurd iteration count that would burn CPU on every unlock attempt.
/// </summary>
public const int MaxPbkdf2Iterations = 10_000_000;
/// <summary>
/// Initializes a new <see cref="EncryptionMetadata"/>. Each argument is validated
/// against the documented contract; invalid values throw <see cref="ArgumentException"/>
/// naming the offending field.
/// </summary>
/// <param name="Algorithm">Symmetric algorithm name; must equal <see cref="SupportedAlgorithm"/>.</param>
/// <param name="Kdf">Key-derivation function name; must equal <see cref="SupportedKdf"/>.</param>
/// <param name="Iterations">PBKDF2 iteration count; must lie in [<see cref="MinPbkdf2Iterations"/>, <see cref="MaxPbkdf2Iterations"/>].</param>
/// <param name="SaltB64">Base64-encoded PBKDF2 salt; must be non-null (may be empty for the seed pattern used by BundleSerializer.Pack).</param>
/// <param name="IvB64">Base64-encoded AES-GCM IV; must be non-null (may be empty for the seed pattern used by BundleSerializer.Pack).</param>
/// <exception cref="ArgumentException">Thrown when any field violates the documented contract.</exception>
public EncryptionMetadata(string Algorithm, string Kdf, int Iterations, string SaltB64, string IvB64)
{
if (!string.Equals(Algorithm, SupportedAlgorithm, StringComparison.Ordinal))
{
throw new ArgumentException(
$"{nameof(Algorithm)} must be '{SupportedAlgorithm}'; got '{Algorithm}'.",
nameof(Algorithm));
}
if (!string.Equals(Kdf, SupportedKdf, StringComparison.Ordinal))
{
throw new ArgumentException(
$"{nameof(Kdf)} must be '{SupportedKdf}'; got '{Kdf}'.",
nameof(Kdf));
}
if (Iterations < MinPbkdf2Iterations || Iterations > MaxPbkdf2Iterations)
{
throw new ArgumentException(
$"{nameof(Iterations)} must be between {MinPbkdf2Iterations} and {MaxPbkdf2Iterations}; got {Iterations}.",
nameof(Iterations));
}
ArgumentNullException.ThrowIfNull(SaltB64);
ArgumentNullException.ThrowIfNull(IvB64);
this.Algorithm = Algorithm;
this.Kdf = Kdf;
this.Iterations = Iterations;
this.SaltB64 = SaltB64;
this.IvB64 = IvB64;
}
/// <summary>Symmetric algorithm name (always <see cref="SupportedAlgorithm"/>).</summary>
public string Algorithm { get; init; }
/// <summary>Key-derivation function name (always <see cref="SupportedKdf"/>).</summary>
public string Kdf { get; init; }
/// <summary>PBKDF2 iteration count.</summary>
public int Iterations { get; init; }
/// <summary>Base64-encoded PBKDF2 salt.</summary>
public string SaltB64 { get; init; }
/// <summary>Base64-encoded AES-GCM IV.</summary>
public string IvB64 { get; init; }
}
@@ -242,6 +242,18 @@ public class ExternalSystemClient : IExternalSystemClient
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken)
{
// ExternalSystemGateway-022: validate the verb against the documented set
// (GET/POST/PUT/PATCH/DELETE — per ESG-023's design-doc reconciliation)
// BEFORE constructing the request. `new HttpMethod(string)` accepts any
// token-character string (e.g. "FOO", "DLETE"), and the body-vs-query
// branch below only knows POST/PUT/PATCH and GET/DELETE — so an
// unsupported verb would dispatch silently with parameters sent to
// neither body nor query, and the script would only see a remote 4xx.
// Rejecting at the gateway entry surfaces the misconfiguration with a
// clear ArgumentException naming the offending verb. Case-insensitive
// match: the entity column carries free-form strings.
ValidateHttpMethod(method.HttpMethod);
var client = _httpClientFactory.CreateClient($"ExternalSystem_{system.Name}");
var url = BuildUrl(system.EndpointUrl, method.Path, parameters, method.HttpMethod);
@@ -357,6 +369,39 @@ public class ExternalSystemClient : IExternalSystemClient
/// </summary>
private const int MaxErrorBodyChars = 2048;
/// <summary>
/// ExternalSystemGateway-022: documented HTTP-verb allowlist. Matches the
/// design doc's enumerated set (GET/POST/PUT/PATCH/DELETE per ESG-023) and
/// the body-vs-query branching above; any addition here must update both.
/// </summary>
private static readonly HashSet<string> SupportedHttpMethods = new(StringComparer.OrdinalIgnoreCase)
{
"GET", "POST", "PUT", "PATCH", "DELETE",
};
/// <summary>
/// Rejects HTTP verbs the gateway does not support. Throws
/// <see cref="ArgumentException"/> for null/empty input or any string outside
/// the documented allowlist. Case-insensitive — the entity column carries
/// operator-authored strings.
/// </summary>
private static void ValidateHttpMethod(string httpMethod)
{
if (string.IsNullOrWhiteSpace(httpMethod))
{
throw new ArgumentException(
"HTTP method must be one of GET/POST/PUT/PATCH/DELETE; got null or empty.",
nameof(httpMethod));
}
if (!SupportedHttpMethods.Contains(httpMethod))
{
throw new ArgumentException(
$"HTTP method '{httpMethod}' is not supported. Allowed verbs: GET, POST, PUT, PATCH, DELETE.",
nameof(httpMethod));
}
}
private static string Truncate(string value, int maxChars)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
@@ -67,7 +67,14 @@ public static class EndpointExtensions
JsonElement? body = null;
try
{
if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType?.Contains("json") == true)
// InboundAPI-020: the content-type sniff must be case-insensitive — a
// request with `application/JSON` or `Application/Json` is still JSON
// and must enter the body-parsing path. The previous case-sensitive
// `Contains("json")` silently skipped JSON deserialization for any
// capitalised value, leaving `body = null` and surfacing required
// parameters as 400 "missing" even though the caller sent a valid body.
if (httpContext.Request.ContentLength > 0
|| httpContext.Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
using var doc = await JsonDocument.ParseAsync(
httpContext.Request.Body, cancellationToken: httpContext.RequestAborted);
@@ -27,8 +27,38 @@ public class InboundScriptExecutor
// request for a broken method re-runs the expensive Roslyn compilation — a CPU
// amplification vector since the inbound API has no rate limiting. The entry is
// cleared whenever the method is (re)compiled via CompileAndRegister.
//
// InboundAPI-024: bound the cache so a spam attack of unique method names cannot
// grow it without bound. Once the cap is reached new bad-method records are
// dropped — the cache is just a fast-fail optimisation; the per-request DB
// lookup remains the correctness path.
private const int KnownBadMethodsCap = 1000;
private readonly ConcurrentDictionary<string, byte> _knownBadMethods = new();
/// <summary>
/// InboundAPI-024 diagnostic helper — returns the current size of the
/// known-bad-methods cache so tests can assert the cap is honoured. Internal
/// so the cache itself stays an implementation detail.
/// </summary>
internal int KnownBadMethodCount => _knownBadMethods.Count;
/// <summary>
/// InboundAPI-024: records <paramref name="methodName"/> in the known-bad-methods
/// cache only if the cache has not reached <see cref="KnownBadMethodsCap"/>.
/// Once full, new records are dropped (paying the cheap recompile next time
/// rather than leaking memory under a unique-name flood). Existing entries are
/// not touched — they remain capped fast-fail records until cleared on a
/// successful (re)compile in <see cref="CompileAndRegister"/>.
/// </summary>
private void TryRecordBadMethod(string methodName)
{
if (_knownBadMethods.ContainsKey(methodName))
return;
if (_knownBadMethods.Count >= KnownBadMethodsCap)
return;
_knownBadMethods.TryAdd(methodName, 0);
}
private readonly IServiceProvider _serviceProvider;
/// <summary>
@@ -73,8 +103,10 @@ public class InboundScriptExecutor
if (handler == null)
{
// InboundAPI-009: record the failure so the lazy-compile path does not
// keep recompiling a broken script on every request.
_knownBadMethods[method.Name] = 0;
// keep recompiling a broken script on every request. InboundAPI-024:
// routed through the capped TryRecordBadMethod helper so the cache
// cannot grow without bound under a flood of unique method names.
TryRecordBadMethod(method.Name);
return false;
}
@@ -230,7 +262,9 @@ public class InboundScriptExecutor
if (compiled == null)
{
// Cache the failure so the next request short-circuits above.
_knownBadMethods[method.Name] = 0;
// InboundAPI-024: routed through TryRecordBadMethod so the
// cache is bounded under a flood of unique method names.
TryRecordBadMethod(method.Name);
return new InboundScriptResult(false, null, "Script compilation failed for this method");
}
handler = _scriptHandlers.GetOrAdd(method.Name, compiled);
@@ -53,7 +53,13 @@ public class EventLogQueryService : IEventLogQueryService
{
try
{
var pageSize = request.PageSize > 0 ? request.PageSize : _options.QueryPageSize;
// SiteEventLogging-017: clamp caller-supplied PageSize to a hard upper
// bound so a central client sending int.MaxValue can't force the query
// to materialise the entire log into a single list while holding the
// shared write lock. Silent clamp — misconfigured clients still get a
// usable response.
var requestedSize = request.PageSize > 0 ? request.PageSize : _options.QueryPageSize;
var pageSize = Math.Min(requestedSize, _options.MaxQueryPageSize);
var whereClauses = new List<string>();
var parameters = new List<SqliteParameter>();
@@ -10,6 +10,21 @@ public class SiteEventLogOptions
public string DatabasePath { get; set; } = "site_events.db";
/// <summary>Maximum number of rows returned per paginated query; default 500.</summary>
public int QueryPageSize { get; set; } = 500;
/// <summary>
/// SiteEventLogging-017: hard upper bound on a caller-supplied <c>PageSize</c>. A
/// misbehaving or hostile central client that requests <c>int.MaxValue</c> would
/// otherwise force the query to materialise the entire log into a single list while
/// holding the shared write lock. Silent clamp; default 500 matches
/// <see cref="QueryPageSize"/>.
/// </summary>
public int MaxQueryPageSize { get; set; } = 500;
/// <summary>Interval between purge runs; default 24 hours.</summary>
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// SiteEventLogging-015: bound on the background write queue. Default 10 000 events.
/// Overflow uses <c>BoundedChannelFullMode.DropOldest</c> — callers never block; the
/// dropped event's <c>Task</c> is faulted and <c>FailedWriteCount</c> is incremented
/// so the drop is observable.
/// </summary>
public int WriteQueueCapacity { get; set; } = 10_000;
}
@@ -17,12 +17,16 @@ namespace ScadaLink.SiteEventLogging;
/// <see cref="WithConnection"/>, which serialises callers on a shared lock.
/// </para>
/// <para>
/// Event recording is offloaded to a dedicated background writer thread (fed by an
/// unbounded <see cref="Channel{T}"/>). <see cref="LogEventAsync"/> only validates
/// its arguments and enqueues, so callers — typically Akka actor threads on hot
/// paths — never block on disk I/O or on contention for the write lock. The
/// returned <see cref="Task"/> completes once the event is durably persisted and
/// faults if the write fails, so failures are observable rather than swallowed.
/// Event recording is offloaded to a dedicated background writer thread (fed by a
/// <em>bounded</em> <see cref="Channel{T}"/>; capacity <see cref="SiteEventLogOptions.WriteQueueCapacity"/>,
/// default 10 000, overflow <see cref="BoundedChannelFullMode.DropOldest"/>).
/// <see cref="LogEventAsync"/> only validates its arguments and enqueues, so callers —
/// typically Akka actor threads on hot paths — never block on disk I/O or on
/// contention for the write lock. The returned <see cref="Task"/> completes once the
/// event is durably persisted and faults if the write fails. SiteEventLogging-015:
/// when a queued event is evicted to make room for a newer one, that event's Task
/// is faulted with <see cref="InvalidOperationException"/> and
/// <see cref="FailedWriteCount"/> is incremented so the drop is observable.
/// </para>
/// </remarks>
public class SiteEventLogger : ISiteEventLogger, IDisposable
@@ -55,11 +59,26 @@ public class SiteEventLogger : ISiteEventLogger, IDisposable
InitializeSchema();
_writeQueue = Channel.CreateUnbounded<PendingEvent>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
// SiteEventLogging-015: bounded queue with DropOldest preserves the
// "callers never block" guarantee (SiteEventLogging-005) while putting an
// upper bound on memory under sustained writer slowness. Drops are
// observable — itemDropped faults the evicted Task and increments
// FailedWriteCount.
var capacity = Math.Max(1, options.Value.WriteQueueCapacity);
_writeQueue = Channel.CreateBounded<PendingEvent>(
new BoundedChannelOptions(capacity)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropOldest,
},
itemDropped: dropped =>
{
Interlocked.Increment(ref _failedWriteCount);
dropped.Completion.TrySetException(
new InvalidOperationException(
$"Event was dropped because the write queue exceeded its bounded capacity ({capacity})."));
});
_writerLoop = Task.Run(ProcessWriteQueueAsync);
}
@@ -141,6 +160,16 @@ public class SiteEventLogger : ISiteEventLogger, IDisposable
cmd.ExecuteNonQuery();
}
/// <summary>
/// SiteEventLogging-020: closed set of allowed severities. Case-sensitive to
/// match the SQLite default <c>BINARY</c> collation used by the query filter —
/// a row stored as <c>"error"</c> would be invisible to a query filtering on
/// <c>"Error"</c>, so the contract on the way in must match the contract on
/// the way out.
/// </summary>
private static readonly HashSet<string> AllowedSeverities =
new(StringComparer.Ordinal) { "Info", "Warning", "Error" };
/// <inheritdoc />
public Task LogEventAsync(
string eventType,
@@ -155,6 +184,15 @@ public class SiteEventLogger : ISiteEventLogger, IDisposable
ArgumentException.ThrowIfNullOrWhiteSpace(source);
ArgumentException.ThrowIfNullOrWhiteSpace(message);
// SiteEventLogging-020: reject unknown severities so the query-time filter
// (case-sensitive BINARY collation) and the documented enum stay in sync.
if (!AllowedSeverities.Contains(severity))
{
throw new ArgumentException(
$"Severity '{severity}' is not one of the allowed values: Info, Warning, Error.",
nameof(severity));
}
var pending = new PendingEvent(
DateTimeOffset.UtcNow.ToString("o"),
eventType,
@@ -226,13 +226,37 @@ public class InstanceActor : ReceiveActor
var resolved = _configuration?.Attributes
.FirstOrDefault(a => a.CanonicalName == command.AttributeName);
var isDataSourced = resolved != null
&& !string.IsNullOrEmpty(resolved.DataSourceReference)
// SiteRuntime-025: reject writes targeting an attribute that does not exist
// on the deployed instance. Without this check, an inbound API
// SetAttribute("notARealAttr", ...) would pollute the in-memory
// _attributes dictionary, publish a synthetic AttributeValueChanged to
// debug-view subscribers, and persist a durable static-override row that
// resurrects on every restart. The override row is also outside the
// ClearStaticOverridesAsync window for unknown names. Refuse the write
// and let the caller see the failure, mirroring the script trust model's
// "scripts can only read/write attributes on their own instance" framing.
if (resolved == null)
{
_logger.LogWarning(
"SetAttribute rejected — attribute '{Attribute}' is not defined on instance '{Instance}'",
command.AttributeName, _instanceUniqueName);
Sender.Tell(new SetStaticAttributeResponse(
command.CorrelationId,
_instanceUniqueName,
command.AttributeName,
false,
$"Unknown attribute '{command.AttributeName}'",
DateTimeOffset.UtcNow));
return;
}
var isDataSourced =
!string.IsNullOrEmpty(resolved.DataSourceReference)
&& !string.IsNullOrEmpty(resolved.BoundDataConnectionName);
if (isDataSourced)
{
HandleSetDataAttribute(command, resolved!);
HandleSetDataAttribute(command, resolved);
return;
}
+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)
@@ -65,6 +65,7 @@ public sealed class BundleImporter : IBundleImporter
private readonly INotificationRepository _notificationRepo;
private readonly IInboundApiRepository _inboundApiRepo;
private readonly IBundleSessionStore _sessionStore;
private readonly BundleUnlockRateLimiter _unlockRateLimiter;
private readonly IOptions<TransportOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly SemanticValidator _semanticValidator;
@@ -93,6 +94,7 @@ public sealed class BundleImporter : IBundleImporter
BundleSecretEncryptor encryptor,
EntitySerializer entitySerializer,
IBundleSessionStore sessionStore,
BundleUnlockRateLimiter unlockRateLimiter,
IOptions<TransportOptions> options,
TimeProvider timeProvider,
ITemplateEngineRepository templateRepo,
@@ -109,6 +111,7 @@ public sealed class BundleImporter : IBundleImporter
_encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor));
_entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer));
_sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
_unlockRateLimiter = unlockRateLimiter ?? throw new ArgumentNullException(nameof(unlockRateLimiter));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_templateRepo = templateRepo ?? throw new ArgumentNullException(nameof(templateRepo));
@@ -0,0 +1,35 @@
namespace ScadaLink.Transport.Import;
/// <summary>
/// Transport-004: thrown by <see cref="BundleImporter.LoadAsync"/> when the caller
/// has exceeded the configured per-IP-per-hour unlock attempt cap
/// (<see cref="TransportOptions.MaxUnlockAttemptsPerIpPerHour"/>). The 429-equivalent
/// signal: the caller must wait for the trailing-hour window to roll forward before
/// another passphrase attempt is accepted.
/// </summary>
public sealed class BundleUnlockRateLimitedException : Exception
{
/// <summary>
/// Rate-limit key the limiter rejected the attempt against — the caller IP when
/// supplied, or the bundle's content hash as the architectural fallback (the
/// importer has no <c>IHttpContext</c> dependency by design).
/// </summary>
public string ClientKey { get; }
/// <summary>Per-window cap that was reached.</summary>
public int Limit { get; }
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimitedException"/>.
/// </summary>
/// <param name="clientKey">The rate-limit key that exceeded its budget.</param>
/// <param name="limit">The configured per-window cap.</param>
public BundleUnlockRateLimitedException(string clientKey, int limit)
: base(
$"Bundle unlock rate limit reached ({limit} attempts per hour). "
+ "Wait for the trailing-hour window to expire before retrying.")
{
ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey));
Limit = limit;
}
}
@@ -0,0 +1,155 @@
using System.Collections.Concurrent;
namespace ScadaLink.Transport.Import;
/// <summary>
/// Transport-004: in-memory sliding-window rate limiter for bundle-unlock passphrase
/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap
/// (default 10) as a brute-force defence against a stolen bundle; this class is the
/// minimal server-side implementation.
/// <para>
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
/// of attempt timestamps. <see cref="TryRegisterAttempt"/> first prunes entries older
/// than the configured window, then either appends the current timestamp and returns
/// <c>true</c> if the count is still under the threshold, or refuses to append and
/// returns <c>false</c> if appending would cross it. The trailing-hour count is the
/// queue length post-prune.
/// </para>
/// <para>
/// Storage is a process-local <see cref="ConcurrentDictionary{TKey,TValue}"/>. The
/// counters do not survive a host restart — that is by design: a restart resets the
/// brute-force window in favour of legitimate operators after an outage. Persisting
/// the counters would require a multi-node consensus story the simple in-memory
/// design avoids.
/// </para>
/// <para>
/// Thread-safety: the per-key queue is protected by a per-key lock taken inside the
/// dictionary value; the dictionary itself is concurrent. The class is safe to call
/// from multiple threads / circuits without external coordination.
/// </para>
/// </summary>
public sealed class BundleUnlockRateLimiter
{
/// <summary>
/// Default trailing window. The design doc's "per-IP-per-hour" wording fixes this
/// at 60 minutes; a constructor overload accepts a different window for tests.
/// </summary>
public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, AttemptBucket> _buckets = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _window;
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> using the documented
/// 1-hour trailing window and the system clock. Suitable for production DI.
/// </summary>
public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
{
}
/// <summary>
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> with an injected clock
/// (for deterministic tests) and a custom trailing window.
/// </summary>
/// <param name="timeProvider">Clock used for both timestamping new attempts and pruning expired ones.</param>
/// <param name="window">Trailing window over which attempts are counted (typically 1 hour).</param>
public BundleUnlockRateLimiter(TimeProvider timeProvider, TimeSpan window)
{
ArgumentNullException.ThrowIfNull(timeProvider);
if (window <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive.");
}
_timeProvider = timeProvider;
_window = window;
}
/// <summary>
/// Attempts to register a new passphrase try against the configured per-key
/// limit. Returns <c>true</c> when the attempt is permitted (and recorded);
/// returns <c>false</c> when the key has exhausted its budget for the trailing
/// window — the caller should reject the unlock request with a 429-equivalent.
/// </summary>
/// <param name="clientKey">
/// Opaque caller identifier — typically the remote IP, but any stable per-source
/// string is acceptable (the limiter does not interpret it). Trimmed for matching.
/// </param>
/// <param name="maxAttemptsPerWindow">
/// The trailing-window cap (e.g. <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
/// default 10). Must be at least 1.
/// </param>
/// <returns>
/// <c>true</c> if the attempt was registered (within budget); <c>false</c> if the
/// caller has already used <paramref name="maxAttemptsPerWindow"/> within the
/// trailing window.
/// </returns>
public bool TryRegisterAttempt(string clientKey, int maxAttemptsPerWindow)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
if (maxAttemptsPerWindow < 1)
{
throw new ArgumentOutOfRangeException(
nameof(maxAttemptsPerWindow), "Limit must be at least 1.");
}
var bucket = _buckets.GetOrAdd(clientKey.Trim(), _ => new AttemptBucket());
var now = _timeProvider.GetUtcNow();
var cutoff = now - _window;
lock (bucket)
{
// Prune expired entries first so a caller that paused longer than the
// window starts the next round at zero — not penalised by stale rows.
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
{
bucket.Timestamps.Dequeue();
}
if (bucket.Timestamps.Count >= maxAttemptsPerWindow)
{
return false;
}
bucket.Timestamps.Enqueue(now);
return true;
}
}
/// <summary>
/// Returns the number of recorded attempts for <paramref name="clientKey"/> still
/// within the trailing window. Primarily for tests / diagnostics; not part of the
/// hot-path.
/// </summary>
public int GetAttemptCount(string clientKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
if (!_buckets.TryGetValue(clientKey.Trim(), out var bucket))
{
return 0;
}
var cutoff = _timeProvider.GetUtcNow() - _window;
lock (bucket)
{
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
{
bucket.Timestamps.Dequeue();
}
return bucket.Timestamps.Count;
}
}
/// <summary>
/// Per-key queue of attempt timestamps. A class (rather than a bare
/// <see cref="Queue{T}"/>) so the dictionary value identity is stable across
/// concurrent <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey,Func{TKey,TValue})"/>
/// races — letting the per-bucket lock guard the queue mutations.
/// </summary>
private sealed class AttemptBucket
{
public Queue<DateTimeOffset> Timestamps { get; } = new();
}
}
@@ -35,6 +35,10 @@ public static class ServiceCollectionExtensions
services.AddScoped<DependencyResolver>();
services.AddScoped<IBundleExporter, BundleExporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
// T-004: per-IP-per-hour unlock rate limiter — design doc §11. Singleton
// so the trailing-hour window is shared across every importer scope; the
// counters live in-memory and reset on host restart (by design).
services.AddSingleton<BundleUnlockRateLimiter>();
// T-007: periodic eviction sweep so abandoned sessions clear without
// needing a fresh Get() to trigger lazy eviction.
services.AddHostedService<BundleSessionEvictionService>();