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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user