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
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 8 |
| Open findings | 7 |
## Summary
@@ -732,9 +732,11 @@ describe the corrupt-typed-row branch. Regression tests added in
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/Transport/EncryptionMetadata.cs:3-8` |
**Resolution (2026-05-28):** Converted `EncryptionMetadata` to a non-positional `record` with an explicit constructor that enforces invariants at the type boundary: `Algorithm` must equal `"AES-256-GCM"`, `Kdf` must equal `"PBKDF2-SHA256"`, `Iterations` must lie in `[MinPbkdf2Iterations=100_000, MaxPbkdf2Iterations=10_000_000]`, and `SaltB64`/`IvB64` must be non-null (empty permitted for the BundleSerializer.Pack seed pattern). Invalid values throw `ArgumentException` (or `ArgumentNullException`) naming the offending field. Added `EncryptionMetadataTests` covering valid construction, unknown algorithm/KDF (including case sensitivity), out-of-range iteration counts (including both boundaries), and null salt/IV. Updated `BundleSecretEncryptorTests` / `BundleSerializerTests` to use `MinPbkdf2Iterations` instead of the prior 10_000 placeholder.
**Description**
`EncryptionMetadata` is a positional record that carries the bundle's encryption parameters
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 |
| Open findings | 5 |
## Summary
@@ -1224,9 +1224,11 @@ ever leaked in the warning text would close the test gap as well.
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:233` |
**Resolution (2026-05-28):** Added a `ValidateHttpMethod` helper called at the top of `InvokeHttpAsync` that rejects any verb outside the documented `GET/POST/PUT/PATCH/DELETE` allowlist (matching ESG-023's design-doc reconciliation) with a clear `ArgumentException` naming the offending verb. Allowlist is a `HashSet<string>` with `OrdinalIgnoreCase` so the operator-authored entity column is case-insensitive. Regression tests `Call_UnsupportedHttpMethod_ThrowsArgumentException` (Theory: FOO/DLETE/GIT/OPTIONS/HEAD) and `Call_DocumentedHttpMethod_IsAccepted` (Theory: GET/get/Post/PATCH/delete) cover the rejection and the case-insensitive accept paths.
**Description**
`InvokeHttpAsync` constructs the request method directly from the string column:
+22 -2
View File
@@ -985,9 +985,18 @@ bodyless request through the middleware.
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:70` |
**Resolution (2026-05-28):** swapped the case-sensitive `Contains("json")`
substring match for `Contains("json", StringComparison.OrdinalIgnoreCase)` so
`application/JSON` / `Application/Json` / `APPLICATION/JSON` all enter the
JSON-deserialization path. Regression test
`EndpointContentTypeTests.ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing`
(theory, 4 casings) drives a TestServer-hosted `MapInboundAPI` end-to-end with
a required Integer parameter — proving body parsing actually ran by asserting
the parameter reaches the handler.
**Description**
`HandleInboundApiRequest` parses the JSON body only when
@@ -1164,9 +1173,20 @@ resolved key name after successful auth, but is absent on auth failures).
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:30`, `:77`, `:223`, `:233` |
**Resolution (2026-05-28):** capped the cache at `KnownBadMethodsCap = 1000`
entries via a new `TryRecordBadMethod` helper that short-circuits when the cap
is reached — both `CompileAndRegister` and the `ExecuteAsync` lazy-compile path
now route through it. Once full, new bad-method records are dropped; the cache
is just a fast-fail optimisation and the per-request DB lookup remains the
correctness path. Regression tests
`KnownBadMethodsCache_SizeNeverExceedsCap_UnderUniqueNameFlood` and
`KnownBadMethodsCache_LazyCompilePath_AlsoCappedUnderUniqueNameFlood` flood
`cap + 500` / `cap + 250` unique broken methods and assert the cache size never
exceeds the cap. Internal `KnownBadMethodCount` exposed for testability only.
**Description**
The InboundAPI-009 fix introduced `_knownBadMethods`, a `ConcurrentDictionary<string, byte>`
+12 -23
View File
@@ -41,9 +41,9 @@ module file and counted in **Total**.
|----------|---------------|
| Critical | 0 |
| High | 0 |
| Medium | 37 |
| Low | 67 |
| **Total** | **104** |
| Medium | 32 |
| Low | 61 |
| **Total** | **93** |
## Module Status
@@ -53,25 +53,25 @@ module file and counted in **Total**.
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/3 | 5 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/5 | 5 | 33 |
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 14 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/5 | 6 | 23 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/5 | 5 | 23 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/4 | 5 | 22 |
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/2 | 5 | 24 |
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/4 | 5 | 24 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/2 | 4 | 23 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 23 |
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/3 | 4 | 23 |
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/5 | 6 | 22 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 25 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/2 | 4 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/2 | 3 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/2 | 4 | 25 |
| [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 21 |
| [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/2 | 4 | 6 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 26 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 26 |
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/3 | 6 | 24 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/4/1 | 5 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/3 | 5 | 12 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/0 | 3 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/3 | 4 | 12 |
## Pending Findings
@@ -88,7 +88,7 @@ _None open._
_None open._
### Medium (37)
### Medium (32)
| ID | Module | Title |
|----|--------|-------|
@@ -97,7 +97,6 @@ _None open._
| AuditLog-005 | [AuditLog](AuditLog/findings.md) | `GetBacklogStatsAsync` holds the SQLite hot-path write lock for the full COUNT+MIN scan |
| CLI-017 | [CLI](CLI/findings.md) | `BundleCommands.RunBundleCommandAsync` duplicates `ExecuteCommandAsync` and breaks the auth exit-code contract |
| CLI-019 | [CLI](CLI/findings.md) | `bundle export` decodes the entire base64 bundle into memory before writing |
| Commons-015 | [Commons](Commons/findings.md) | `EncryptionMetadata` accepts any algorithm string and any iteration count |
| Communication-017 | [Communication](Communication/findings.md) | `_inProgressDeployments` grows unboundedly — successful deployments are never cleaned up |
| ConfigurationDatabase-016 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | `InboundApiRepository.GetApiKeyByValueAsync` hashes the candidate with the unpeppered `ApiKeyHasher.Default` |
| ConfigurationDatabase-017 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | Stub-attach delete on `DeploymentRecord` bypasses optimistic concurrency |
@@ -116,8 +115,6 @@ _None open._
| NotificationService-024 | [NotificationService](NotificationService/findings.md) | No test affirms the central-only invariant; the orphaned-path tests give a false coverage signal |
| SiteCallAudit-001 | [SiteCallAudit](SiteCallAudit/findings.md) | SupervisorStrategy override is dead code; XML claims Resume that is not enforced |
| SiteCallAudit-003 | [SiteCallAudit](SiteCallAudit/findings.md) | `OnUpsertAsync` does not refresh `IngestedAtUtc`; direct-write callers must remember to stamp it |
| SiteEventLogging-015 | [SiteEventLogging](SiteEventLogging/findings.md) | Background write queue is unbounded; can grow without limit under sustained writer slowness |
| SiteEventLogging-017 | [SiteEventLogging](SiteEventLogging/findings.md) | Central client's `PageSize` is unbounded; defeats the "configurable page size" design rationale |
| SiteRuntime-021 | [SiteRuntime](SiteRuntime/findings.md) | `HandleDeployArtifacts` updates `DataConnections` in SQLite but never sends `CreateConnectionCommand` to the DCL |
| SiteRuntime-022 | [SiteRuntime](SiteRuntime/findings.md) | `AuditingDbCommand.DbConnection.set` uses reflection to read `AuditingDbConnection._inner` |
| StoreAndForward-019 | [StoreAndForward](StoreAndForward/findings.md) | Notifications park after `DefaultMaxRetries` exhaustion, contradicting "retried until central acks" |
@@ -126,11 +123,9 @@ _None open._
| TemplateEngine-018 | [TemplateEngine](TemplateEngine/findings.md) | `DiffService` reports no entries for added/removed/changed connections |
| TemplateEngine-019 | [TemplateEngine](TemplateEngine/findings.md) | `TemplateResolver.BuildInheritanceChain` still uses the `0`-as-no-parent sentinel that was removed from `CycleDetector` |
| TemplateEngine-020 | [TemplateEngine](TemplateEngine/findings.md) | `Create*` audit entries are written with `EntityId = "0"` before `SaveChangesAsync` populates the real key |
| TemplateEngine-021 | [TemplateEngine](TemplateEngine/findings.md) | `MoveTemplateAsync` skips folder cycle and sibling-name-collision validation |
| Transport-004 | [Transport](Transport/findings.md) | `MaxUnlockAttemptsPerIpPerHour` option is declared but never enforced |
| Transport-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests |
### Low (67)
### Low (61)
| ID | Module | Title |
|----|--------|-------|
@@ -167,7 +162,6 @@ _None open._
| DeploymentManager-023 | [DeploymentManager](DeploymentManager/findings.md) | `BuildDeployArtifactsCommandAsync` re-queries system-wide artifacts once per site |
| DeploymentManager-024 | [DeploymentManager](DeploymentManager/findings.md) | Test probe actors hold mutable static state across tests |
| ExternalSystemGateway-021 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `ApplyAuth` silently sends an unauthenticated request on unknown `AuthType`, empty `AuthConfiguration`, or malformed Basic config |
| ExternalSystemGateway-022 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `new HttpMethod(method.HttpMethod)` accepts any string at runtime; an invalid HTTP verb fails only at call time |
| HealthMonitoring-018 | [HealthMonitoring](HealthMonitoring/findings.md) | Same counter-reset-before-publish hazard in `CentralHealthReportLoop` |
| HealthMonitoring-021 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralSiteId = "central"` reserved constant silently collides with a real site named "central" |
| HealthMonitoring-022 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralHealthReportLoopTests` uses real-time `PeriodicTimer` + `Task.Delay`; flake-prone on slow CI |
@@ -177,9 +171,7 @@ _None open._
| Host-021 | [Host](Host/findings.md) | Microsoft `Logging:LogLevel` section in `appsettings.json` is dead config under Serilog |
| Host-022 | [Host](Host/findings.md) | `ParseLevel` silently coerces unrecognised `MinimumLevel` to `Information` |
| InboundAPI-019 | [InboundAPI](InboundAPI/findings.md) | `EnableBuffering()` called unconditionally on every request, including bodyless requests |
| InboundAPI-020 | [InboundAPI](InboundAPI/findings.md) | `ContentType.Contains("json")` is case-sensitive; `application/JSON` with no Content-Length skips body parsing |
| InboundAPI-023 | [InboundAPI](InboundAPI/findings.md) | `EndpointExtensions.HandleInboundApiRequest` composition wiring has no test coverage |
| InboundAPI-024 | [InboundAPI](InboundAPI/findings.md) | `_knownBadMethods` is unbounded — an attacker can grow the cache by spamming distinct method names against the audit middleware path |
| ManagementService-023 | [ManagementService](ManagementService/findings.md) | HandleQueryDeployments unfiltered branch is N+1 on instance lookup |
| NotificationOutbox-006 | [NotificationOutbox](NotificationOutbox/findings.md) | `ResolveAdapters` rebuilds the `NotificationType → adapter` dictionary on every dispatch sweep |
| NotificationOutbox-008 | [NotificationOutbox](NotificationOutbox/findings.md) | `FallbackMaxRetries` / `FallbackRetryDelay` path is unreachable in production AND untested |
@@ -190,14 +182,11 @@ _None open._
| SiteCallAudit-002 | [SiteCallAudit](SiteCallAudit/findings.md) | Singleton failover does not wait for in-flight async upserts |
| SiteCallAudit-006 | [SiteCallAudit](SiteCallAudit/findings.md) | Stuck-only paging test does not exercise the multi-page boundary with an interleaved non-stuck row at the cursor |
| SiteEventLogging-018 | [SiteEventLogging](SiteEventLogging/findings.md) | `FailedWriteCount` is exposed but never consumed by Health Monitoring |
| SiteEventLogging-020 | [SiteEventLogging](SiteEventLogging/findings.md) | `severity` and `eventType` are unvalidated free-form strings; doc enumerates a set that is not enforced |
| SiteEventLogging-022 | [SiteEventLogging](SiteEventLogging/findings.md) | `Cache=Shared` is redundant for a single-connection logger |
| SiteEventLogging-023 | [SiteEventLogging](SiteEventLogging/findings.md) | Concurrent-stress test uses a non-volatile `stop` flag |
| SiteRuntime-025 | [SiteRuntime](SiteRuntime/findings.md) | `HandleSetStaticAttribute` persists unknown attribute names as static overrides |
| StoreAndForward-022 | [StoreAndForward](StoreAndForward/findings.md) | `NotifyCachedCallObserverAsync` silently drops the entire audit lifecycle when the message id is not a parseable `TrackedOperationId` |
| StoreAndForward-023 | [StoreAndForward](StoreAndForward/findings.md) | `siteId` silently defaults to empty when no `IStoreAndForwardSiteContext` is registered, degrading audit telemetry correlation |
| StoreAndForward-024 | [StoreAndForward](StoreAndForward/findings.md) | `StopAsync` does not wait for an in-flight retry sweep, so disposed dependencies can be touched after shutdown |
| TemplateEngine-022 | [TemplateEngine](TemplateEngine/findings.md) | `LockEnforcer.ValidateLockChange` enforces "once-locked-stays-locked" for `IsLocked` but not for `LockedInDerived` |
| Transport-008 | [Transport](Transport/findings.md) | `PreviewAsync` issues an N+1 `GetTemplateWithChildrenAsync` per matching template name |
| Transport-009 | [Transport](Transport/findings.md) | `IAuditCorrelationContext.BundleImportId` is mutated on the same scoped instance the AuditService reads |
| Transport-012 | [Transport](Transport/findings.md) | "Bundle Import" filter promised in design doc not surfaced in Configuration Audit Log Viewer UI |
+27 -4
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 8 |
| Open findings | 5 |
## Summary
@@ -753,9 +753,18 @@ background scheduler).
|--|--|
| Severity | Medium |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:58-63` |
**Resolution (2026-05-28):** Switched `SiteEventLogger._writeQueue` to
`Channel.CreateBounded<PendingEvent>` with `FullMode = BoundedChannelFullMode.DropOldest`.
Default capacity 10,000 via new `SiteEventLogOptions.WriteQueueCapacity`. The
`itemDropped` callback faults the dropped event's Task with
`InvalidOperationException` and increments `FailedWriteCount` so both awaiting
callers and Health Monitoring observe drops. `DropOldest` preserves the
SiteEventLogging-005 "callers never block" guarantee. Test:
`LogEventAsync_BoundedQueueDropsOldest_AndFaultsDroppedTask`.
**Description**
`SiteEventLogger` creates its background-writer feeder as
@@ -844,9 +853,16 @@ lexicographic-comparison hazard structurally.
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:55`, `src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs:18` |
**Resolution (2026-05-28):** `EventLogQueryService.ExecuteQuery` now clamps
`pageSize` to new `SiteEventLogOptions.MaxQueryPageSize` (default 500, matching
existing `QueryPageSize` default) before issuing the SQL. Silent clamp rather
than reject so misconfigured clients still get a usable response. Test:
`Query_PageSize_IsClampedToMaxQueryPageSize` (asserts a request of 100,000 is
clamped down with `HasMore = true`).
**Description**
`EventLogQueryService.ExecuteQuery` resolves the effective page size as
@@ -970,9 +986,16 @@ SiteEventLogging.Tests).
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:144-156`, `src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs:14-15` |
**Resolution (2026-05-28):** `LogEventAsync` now validates `severity` against
the closed set `{Info, Warning, Error}` (case-sensitive, matches SQLite
BINARY collation used by the query filter) and throws `ArgumentException`
naming the offending value. `eventType` left intentionally free-form (design
enumerates an open category set). Tests: `LogEventAsync_ThrowsOnUnknownSeverity`
(5 cases) and `LogEventAsync_AcceptsAllDocumentedSeverities` (3 cases).
**Description**
`LogEventAsync` validates `eventType` and `severity` only for non-empty/non-whitespace.
+14 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 |
| Open findings | 5 |
## Summary
@@ -1265,9 +1265,21 @@ into a sync path that calls `_connection.Dispose()` directly.
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:223`, `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:246` |
**Resolution (2026-05-28):** `HandleSetStaticAttribute` now rejects writes
whose `command.AttributeName` does not resolve against
`_configuration.Attributes`. The caller receives
`SetStaticAttributeResponse(Success: false, ErrorMessage: "Unknown attribute
'<name>'")`; no override is persisted, `_attributes` is not mutated, and no
synthetic `AttributeValueChanged` is published — eliminating the
in-memory-pollution, restart-resurrection, and debug-stream spam vectors.
Regression test
`InstanceActorSetAttributeTests.SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride`
exercises an inbound `SetStaticAttributeCommand` with an unknown name and
asserts the failure response, no DCL traffic, and an empty override row.
**Description**
`HandleSetStaticAttribute` resolves the target attribute against
+32 -7
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 |
| Open findings | 4 |
## Summary
@@ -1067,7 +1067,7 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:173` |
**Description**
@@ -1102,9 +1102,21 @@ templates with the same `FolderId == newFolderId` and the same `Name`
(case-insensitive), mirroring `TemplateFolderService.MoveFolderAsync` lines
130142. Add a regression test `MoveTemplate_NameCollisionAtDestination_Fails`.
**Resolution**
**Resolution (2026-05-28):**
_Unresolved._
Resolved (commit `pending`): `MoveTemplateAsync` now loads `GetAllTemplatesAsync`
on any FolderId-changing move and rejects the move if another template at the
destination shares the moved template's name (case-insensitive), mirroring
`TemplateFolderService.MoveFolderAsync`'s sibling-name uniqueness check; the
FolderId is not written when the check fails. Cycle detection is deliberately
not added — a template move changes only `FolderId` and never touches
`ParentTemplateId`, and templates have no folder-children navigation, so no
inheritance- or folder-graph cycle is reachable through this path (the
finding's own description states this; only the sibling-name check applies).
Regression tests: `MoveTemplate_NameCollisionAtDestination_Fails` (case-
insensitive collision rejected, FolderId untouched, `UpdateTemplateAsync` never
called) and `MoveTemplate_NoCollisionAtDestination_Succeeds` (same-named
template in a *different* folder is not a collision).
### TemplateEngine-022 — `LockEnforcer.ValidateLockChange` enforces "once-locked-stays-locked" for `IsLocked` but not for `LockedInDerived`
@@ -1112,7 +1124,7 @@ _Unresolved._
|--|--|
| Severity | Low |
| Category | Documentation & comments |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/LockEnforcer.cs:109`, `src/ScadaLink.TemplateEngine/TemplateService.cs:323`, `src/ScadaLink.TemplateEngine/TemplateService.cs:476`, `src/ScadaLink.TemplateEngine/TemplateService.cs:623` |
**Description**
@@ -1156,7 +1168,20 @@ like `IsLocked`, extend `ValidateLockChange` (or add a sibling
intended to be mutable, update the `LockEnforcer` summary to scope the rule
to `IsLocked` only. Either way, add a test pinning the chosen behaviour.
**Resolution**
**Resolution (2026-05-28):**
_Unresolved._
Resolved (commit `pending`): chose option (b) — `LockedInDerived` is now a
one-way ratchet on base templates, matching the design intent that an existing
block on derived overrides cannot be retroactively re-allowed. Added a sibling
`LockEnforcer.ValidateLockedInDerivedChange(originalLockedInDerived,
proposedLockedInDerived, memberName)` and wired it into `UpdateAttributeAsync`,
`UpdateAlarmAsync`, and `UpdateScriptAsync` (only when the owning template is
*not* derived — derived rows never carry an authoritative `LockedInDerived`,
they inherit the base's value). The `LockEnforcer` class XML summary now
explicitly extends the once-locked-stays-locked rule to both `IsLocked` and
`LockedInDerived` so the documentation matches the enforced behaviour.
Regression tests: `LockEnforcerTests.ValidateLockedInDerivedChange_*` (true→
false rejected, false→true / true→true / false→false accepted) and
`TemplateServiceTests.UpdateAttribute_LockedInDerivedDowngrade_OnBase_Rejected`
(end-to-end on the attribute update path).
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 11 |
| Open findings | 10 |
## Summary
@@ -173,9 +173,11 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/TransportOptions.cs:12`, `docs/requirements/Component-Transport.md` §11 |
**Resolution (2026-05-28):** Added a new `BundleUnlockRateLimiter` class (in-memory, per-key sliding-window counter via `ConcurrentDictionary<string, Queue<DateTimeOffset>>` with per-bucket locking), registered as a singleton in `AddTransport`, and wired into `BundleImporter.LoadAsync` before the decrypt attempt — exceeding the per-window cap throws the new `BundleUnlockRateLimitedException` (429-equivalent). The importer keys the limiter on the bundle's `ContentHash` (it has no `IHttpContext` dependency by design); an IP-aware caller can use the limiter's public `TryRegisterAttempt(clientIp, max)` directly for true per-IP keying. `BundleUnlockRateLimiterTests` covers: N attempts allowed and N+1 rejected; full-window expiry releases the entire budget; partial expiry releases only the aged-out slots (sliding window); per-key isolation; argument validation.
**Description**
`TransportOptions.MaxUnlockAttemptsPerIpPerHour` defaults to 10 and is
@@ -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>();
@@ -0,0 +1,143 @@
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-015: <see cref="EncryptionMetadata"/> must reject malformed envelopes at
/// the type boundary (unknown algorithm, unsupported KDF, sub-minimum or over-cap
/// iteration counts, null salt/IV). Valid construction must round-trip the fields.
/// </summary>
public sealed class EncryptionMetadataTests
{
[Fact]
public void Constructor_WithDocumentedValues_Succeeds()
{
// 600_000 is the design-doc production value; "abc"/"def" are placeholder
// Base64 strings, kept short for test legibility.
var meta = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def");
Assert.Equal("AES-256-GCM", meta.Algorithm);
Assert.Equal("PBKDF2-SHA256", meta.Kdf);
Assert.Equal(600_000, meta.Iterations);
Assert.Equal("abc", meta.SaltB64);
Assert.Equal("def", meta.IvB64);
}
[Theory]
[InlineData("AES-128-CBC")] // weaker algorithm
[InlineData("AES-256-CBC")] // unauthenticated mode
[InlineData("aes-256-gcm")] // case must match exactly
[InlineData("")]
[InlineData("FOO")]
public void Constructor_UnknownAlgorithm_Throws(string algorithm)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: algorithm,
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Algorithm", ex.ParamName);
Assert.Contains("AES-256-GCM", ex.Message);
}
[Theory]
[InlineData("PBKDF2-SHA1")] // weaker hash
[InlineData("argon2id")] // unsupported KDF
[InlineData("pbkdf2-sha256")] // case must match
[InlineData("")]
public void Constructor_UnknownKdf_Throws(string kdf)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: kdf,
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Kdf", ex.ParamName);
Assert.Contains("PBKDF2-SHA256", ex.Message);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(1)]
[InlineData(99_999)] // one below the floor
[InlineData(10_000_001)] // one above the ceiling
[InlineData(int.MaxValue)]
public void Constructor_IterationsOutOfRange_Throws(int iterations)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: iterations,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Iterations", ex.ParamName);
}
[Theory]
[InlineData(100_000)] // OWASP minimum (exact)
[InlineData(600_000)] // design-doc production value
[InlineData(10_000_000)] // ceiling (exact)
public void Constructor_IterationsAtBoundary_Succeeds(int iterations)
{
// Exercises the inclusive boundary check on both ends.
var meta = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: iterations,
SaltB64: "abc",
IvB64: "def");
Assert.Equal(iterations, meta.Iterations);
}
[Fact]
public void Constructor_NullSalt_Throws()
{
// null is rejected; empty is permitted (the seed pattern used by BundleSerializer.Pack).
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: null!,
IvB64: "def"));
}
[Fact]
public void Constructor_NullIv_Throws()
{
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: null!));
}
[Fact]
public void Constructor_EmptySaltAndIv_Succeeds_ForSeedPattern()
{
// BundleSerializer.Pack re-stamps salt/iv from the ciphertext it actually
// writes, so callers (BundleExporter) construct a seed instance with empty
// placeholders. Validation must therefore accept empty here.
var seed = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: string.Empty,
IvB64: string.Empty);
Assert.Equal(string.Empty, seed.SaltB64);
Assert.Equal(string.Empty, seed.IvB64);
}
}
@@ -990,4 +990,58 @@ public class ExternalSystemClientTests
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
}
}
/// <summary>
/// ExternalSystemGateway-022: an HTTP method outside the documented allowlist
/// (GET/POST/PUT/PATCH/DELETE) must be rejected at the gateway entry with a
/// clear <see cref="ArgumentException"/> — not silently dispatched as a
/// non-standard verb whose parameters land in neither body nor query string.
/// </summary>
[Theory]
[InlineData("FOO")]
[InlineData("DLETE")] // common typo for DELETE
[InlineData("GIT")]
[InlineData("OPTIONS")] // valid HTTP verb but outside the gateway's documented set
[InlineData("HEAD")]
public async Task Call_UnsupportedHttpMethod_ThrowsArgumentException(string httpMethod)
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("badVerb", httpMethod, "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
// No handler is needed — validation rejects the verb before any HTTP traffic.
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(new RequestCapturingHandler(HttpStatusCode.OK, "{}")));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => client.CallAsync("TestAPI", "badVerb"));
Assert.Contains(httpMethod, ex.Message);
Assert.Contains("GET", ex.Message);
}
[Theory]
[InlineData("GET")]
[InlineData("get")] // case-insensitive — operator-authored strings
[InlineData("Post")]
[InlineData("PATCH")]
[InlineData("delete")]
public async Task Call_DocumentedHttpMethod_IsAccepted(string httpMethod)
{
// The allowlist is case-insensitive (the entity column is free-form).
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("doIt", httpMethod, "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("TestAPI", "doIt");
Assert.True(result.Success);
}
}
@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.InboundApi;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// InboundAPI-020: the inbound API handler must accept JSON content types
/// case-insensitively. A request with <c>application/JSON</c>,
/// <c>Application/Json</c>, or <c>application/json</c> must all enter the
/// JSON-deserialization path — the previous <c>Contains("json")</c> check
/// was case-sensitive so a capitalised value silently skipped body parsing
/// and any required parameters surfaced as a 400 even though the caller
/// sent a valid JSON body.
/// </summary>
public class EndpointContentTypeTests
{
/// <summary>
/// Stub hasher that returns its input unchanged. Lets the test pre-seed the
/// repository with a known "hash" value without depending on the real
/// HMAC-with-pepper hasher.
/// </summary>
private sealed class IdentityHasher : IApiKeyHasher
{
public string Hash(string keyValue) => keyValue;
}
[Theory]
[InlineData("application/json")]
[InlineData("application/JSON")]
[InlineData("Application/Json")]
[InlineData("APPLICATION/JSON")]
public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType)
{
const string apiKeyValue = "test-key";
const string methodName = "echoParam";
var key = ApiKey.FromHash("test", apiKeyValue);
key.IsEnabled = true;
key.Id = 1;
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
{
Id = 1,
TimeoutSeconds = 10,
// One Integer parameter, required — proves the body was actually
// parsed: if the case-sensitive bug returns, body parsing is
// skipped and the validator reports the missing field as a 400.
ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
};
var repo = Substitute.For<IInboundApiRepository>();
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
.Returns(method);
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
using var host = await BuildHostAsync(repo);
var client = host.GetTestClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{
// Bypass HttpClient's MediaTypeHeaderValue auto-normalization by
// setting the header through TryAddWithoutValidation — we need the
// exact casing reach the server intact.
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}"))
};
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
request.Headers.Add("X-API-Key", apiKeyValue);
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.True(
response.StatusCode == HttpStatusCode.OK,
$"Expected 200 for content-type '{contentType}' but got {(int)response.StatusCode}: {body}");
Assert.Contains("42", body);
}
private static async Task<IHost> BuildHostAsync(IInboundApiRepository repo)
{
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
// RouteHelper depends on IInstanceLocator + IInstanceRouter
// (InboundAPI-017). Tests for content-type handling never
// route, so both can be no-op stubs — the production
// CommunicationServiceInstanceRouter would need a real
// CommunicationService which isn't wired here.
services.AddSingleton(Substitute.For<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI();
services.RemoveAll<IInstanceRouter>();
services.AddSingleton(Substitute.For<IInstanceRouter>());
// The production AddInboundAPI registration of IApiKeyHasher
// requires a configured pepper. Replace it with the identity
// stub so the seeded ApiKey.KeyHash matches "test-key"
// deterministically without depending on configuration.
services.RemoveAll<IApiKeyHasher>();
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
services.AddLogging();
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
});
});
return await hostBuilder.StartAsync();
}
}
@@ -362,6 +362,52 @@ public class InboundScriptExecutorTests
Assert.True(_executor.CompileAndRegister(good));
}
// --- InboundAPI-024: _knownBadMethods must be bounded so a spam attack of
// unique method names cannot grow the cache without bound. ---
[Fact]
public void KnownBadMethodsCache_SizeNeverExceedsCap_UnderUniqueNameFlood()
{
// Flood the executor with bad-method names well past the cache cap. The
// cache must stabilise at or below the cap — any further unique bad name
// is dropped rather than added (the per-request DB lookup remains the
// correctness path; this cache is only a fast-fail optimisation).
const int cap = 1000;
const int floodCount = cap + 500;
for (var i = 0; i < floodCount; i++)
{
var bad = new ApiMethod($"bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
Assert.False(_executor.CompileAndRegister(bad));
}
Assert.True(
_executor.KnownBadMethodCount <= cap,
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
}
[Fact]
public async Task KnownBadMethodsCache_LazyCompilePath_AlsoCappedUnderUniqueNameFlood()
{
// The lazy-compile path (ExecuteAsync on an unregistered method) records
// failures via the same capped helper as CompileAndRegister, so flooding
// it with unique URLs must not grow the cache without bound.
const int cap = 1000;
const int floodCount = cap + 250;
for (var i = 0; i < floodCount; i++)
{
var method = new ApiMethod($"lazy-bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
Assert.False(result.Success);
}
Assert.True(
_executor.KnownBadMethodCount <= cap,
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
}
// --- InboundAPI-014: the script return value is validated against ReturnDefinition ---
[Fact]
@@ -316,4 +316,45 @@ public class EventLogQueryServiceTests : IDisposable
cmd.ExecuteNonQuery();
});
}
// --- SiteEventLogging-017: PageSize hard upper bound ---
[Fact]
public async Task Query_PageSize_IsClampedToMaxQueryPageSize()
{
// Tighten the cap to make the assertion deterministic without seeding 100k rows.
var dbPath = Path.Combine(Path.GetTempPath(), $"test_pagesize_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions
{
DatabasePath = dbPath,
QueryPageSize = 500,
MaxQueryPageSize = 3,
});
using var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
var query = new EventLogQueryService(logger, options, NullLogger<EventLogQueryService>.Instance);
try
{
// Seed 5 rows but request PageSize = 100_000 — must be clamped to 3.
for (var i = 0; i < 5; i++)
await logger.LogEventAsync("script", "Info", null, $"src-{i}", $"msg-{i}");
var response = query.ExecuteQuery(new EventLogQueryRequest(
CorrelationId: Guid.NewGuid().ToString(),
SiteId: "site-1",
From: null, To: null,
EventType: null, Severity: null, InstanceId: null, KeywordFilter: null,
ContinuationToken: null,
PageSize: 100_000,
Timestamp: DateTimeOffset.UtcNow));
Assert.Equal(3, response.Entries.Count);
Assert.True(response.HasMore);
}
finally
{
logger.Dispose();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
}
@@ -140,4 +140,34 @@ public class SiteEventLoggerTests : IDisposable
var count = (long)cmd.ExecuteScalar()!;
Assert.Equal(6, count);
}
// --- SiteEventLogging-020: severity validation against the closed set ---
[Theory]
[InlineData("info")] // wrong casing
[InlineData("warn")] // abbreviation
[InlineData("ERROR")] // wrong casing
[InlineData("Debug")] // not in set
[InlineData("Critical")] // not in set
public async Task LogEventAsync_ThrowsOnUnknownSeverity(string badSeverity)
{
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => _logger.LogEventAsync("script", badSeverity, null, "Source", "Message"));
Assert.Contains(badSeverity, ex.Message);
Assert.Contains("Info, Warning, Error", ex.Message);
}
[Theory]
[InlineData("Info")]
[InlineData("Warning")]
[InlineData("Error")]
public async Task LogEventAsync_AcceptsAllDocumentedSeverities(string severity)
{
await _logger.LogEventAsync("script", severity, null, "Source", "Message");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT severity FROM site_events";
var stored = (string)cmd.ExecuteScalar()!;
Assert.Equal(severity, stored);
}
}
@@ -176,4 +176,41 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
Assert.Single(overrides);
Assert.Equal("Backup", overrides["Label"]);
}
// SiteRuntime-025: SetAttribute on an unknown attribute name must NOT
// pollute the in-memory dictionary, NOT publish a synthetic
// AttributeValueChanged, and NOT persist a durable override row.
[Fact]
public async Task SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpUnknown",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Label", Value = "Main", DataType = "String" }
]
};
var dclProbe = CreateTestProbe();
var actor = CreateInstanceActor("PumpUnknown", config, dclProbe.Ref);
actor.Tell(new SetStaticAttributeCommand(
"corr-unknown", "PumpUnknown", "notARealAttr", "x", DateTimeOffset.UtcNow));
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.False(response.Success);
Assert.Contains("Unknown attribute", response.ErrorMessage);
Assert.Contains("notARealAttr", response.ErrorMessage);
// The DCL must NOT receive any write — the attribute does not exist.
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// No durable override row should be persisted for an unknown attribute —
// otherwise the polluting key resurrects on every restart via
// HandleOverridesLoaded.
await Task.Delay(300);
var overrides = await _storage.GetStaticOverridesAsync("PumpUnknown");
Assert.DoesNotContain("notARealAttr", overrides.Keys);
}
}
@@ -211,4 +211,46 @@ public class LockEnforcerTests
Assert.Null(result);
}
// ========================================================================
// TemplateEngine-022: LockedInDerived one-way ratchet
// ========================================================================
[Fact]
public void ValidateLockedInDerivedChange_ClearLocked_ReturnsError()
{
// Once a base template marks a member LockedInDerived, the flag may
// not be cleared — derived overrides previously blocked would
// otherwise become retroactively legal.
var result = LockEnforcer.ValidateLockedInDerivedChange(true, false, "Speed");
Assert.NotNull(result);
Assert.Contains("locked-in-derived", result);
Assert.Contains("cannot be cleared", result);
}
[Fact]
public void ValidateLockedInDerivedChange_LockUnlocked_ReturnsNull()
{
// Setting the flag from false→true is the normal direction.
var result = LockEnforcer.ValidateLockedInDerivedChange(false, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockedInDerivedChange_KeepLocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockedInDerivedChange(true, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockedInDerivedChange_KeepUnlocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockedInDerivedChange(false, false, "Speed");
Assert.Null(result);
}
}
@@ -1086,6 +1086,8 @@ public class TemplateServiceTests
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1098,6 +1100,8 @@ public class TemplateServiceTests
{
var t = new Template("X") { Id = 1, FolderId = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, null, "admin");
@@ -1117,4 +1121,78 @@ public class TemplateServiceTests
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task MoveTemplate_NameCollisionAtDestination_Fails()
{
// TemplateEngine-021: moving "Pump" into a folder that already contains a
// template named "Pump" (case-insensitive) must be rejected before the
// FolderId is persisted. Mirrors TemplateFolderService.MoveFolderAsync
// sibling-name uniqueness, and pins the design rule that naming
// collisions are design-time errors.
var moving = new Template("Pump") { Id = 1, FolderId = null };
var existing = new Template("pump") { Id = 2, FolderId = 7 }; // case-insensitive clash
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, existing });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
// The destination FolderId must NOT have been written when the move is rejected.
Assert.Null(moving.FolderId);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task MoveTemplate_NoCollisionAtDestination_Succeeds()
{
// TemplateEngine-021 companion: a sibling with the same name in a
// *different* folder is not a collision. The move must succeed.
var moving = new Template("Pump") { Id = 1, FolderId = null };
var unrelated = new Template("Pump") { Id = 2, FolderId = 8 }; // different folder
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, unrelated });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(7, result.Value.FolderId);
}
[Fact]
public async Task UpdateAttribute_LockedInDerivedDowngrade_OnBase_Rejected()
{
// TemplateEngine-022: LockedInDerived is a one-way ratchet on base
// templates. Once true, it cannot be cleared — otherwise existing
// derived overrides that were previously blocked would become
// retroactively legal.
var baseTemplate = new Template("Sensor") { Id = 2 }; // base — not derived
var existing = new TemplateAttribute("SetPoint")
{
Id = 100,
TemplateId = 2,
DataType = DataType.Float,
LockedInDerived = true
};
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
var proposed = new TemplateAttribute("SetPoint")
{
DataType = DataType.Float,
LockedInDerived = false // attempt to clear the lock
};
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked-in-derived", result.Error);
Assert.True(existing.LockedInDerived); // unchanged
}
}
@@ -1,12 +1,16 @@
using System.Security.Cryptography;
using System.Text;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Transport.Encryption;
namespace ScadaLink.Transport.Tests.Encryption;
public sealed class BundleSecretEncryptorTests
{
private const int TestIterations = 10_000; // Lower than production for test speed.
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum);
// the production value is 600_000. Using the floor keeps the test fast while
// remaining a valid EncryptionMetadata.Iterations.
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
[Fact]
public void Encrypt_then_Decrypt_roundtrips_arbitrary_bytes()
@@ -102,6 +102,10 @@ public sealed class BundleImporterLoadTests
encryptor: encryptor,
entitySerializer: entitySerializer,
sessionStore: store,
// T-004: the unlock rate limiter shares the test clock so its trailing-hour
// window pruning is deterministic. The window itself is the production
// default (1 hour).
unlockRateLimiter: new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow),
options: iOpts,
timeProvider: clock,
templateRepo: Substitute.For<ITemplateEngineRepository>(),
@@ -0,0 +1,160 @@
using ScadaLink.Transport.Import;
namespace ScadaLink.Transport.Tests.Import;
/// <summary>
/// Transport-004: <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
/// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The
/// limiter accepts any opaque caller key (typically a remote IP); these tests use
/// IP-style strings to mirror the documented intent.
/// </summary>
public sealed class BundleUnlockRateLimiterTests
{
private sealed class TestClock : TimeProvider
{
private DateTimeOffset _now;
public TestClock(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) { _now += delta; }
}
[Fact]
public void TryRegisterAttempt_UnderLimit_ReturnsTrue()
{
// The first N attempts at the same key are permitted; the trailing-hour
// count tracks them.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 1; i <= 10; i++)
{
Assert.True(
limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10),
$"Attempt {i} should be allowed (under the cap).");
}
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Fact]
public void TryRegisterAttempt_AtLimit_RejectsNextAttempt()
{
// N attempts allowed, attempt N+1 rejected — the headline contract.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 11th attempt within the hour exceeds the cap.
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Subsequent attempts also rejected — the limiter does NOT silently let a
// 12th, 13th, ... attempt through (no leak past the cap).
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// And the recorded count never exceeds the cap (rejected attempts are not
// appended to the trailing-hour queue).
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Fact]
public void TryRegisterAttempt_EntriesExpireAfterWindow()
{
// Once the trailing-hour window rolls past every recorded attempt the key
// is fully reset — a legitimate operator returning later is not penalised.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the window's end. Every recorded timestamp is now
// strictly older than (now - window) and must be pruned.
clock.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));
Assert.Equal(0, limiter.GetAttemptCount("10.0.0.1"));
// A fresh full budget is available.
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
[Fact]
public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly()
{
// Sliding window — when only some of the recorded entries have aged out,
// exactly that many slots are released.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
// Five attempts at t=0.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 30 minutes later, five more — saturates the budget.
clock.Advance(TimeSpan.FromMinutes(30));
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the first batch's window. Only those five entries expire;
// the second batch (recorded at t=30) is still within window from t=61.
clock.Advance(TimeSpan.FromMinutes(31));
Assert.Equal(5, limiter.GetAttemptCount("10.0.0.1"));
// Five fresh slots are available.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
[Fact]
public void TryRegisterAttempt_PerKeyIsolation()
{
// The cap is per key — saturating one IP does not affect another.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// A different IP has its own untouched budget.
Assert.True(limiter.TryRegisterAttempt("10.0.0.2", maxAttemptsPerWindow: 10));
Assert.Equal(1, limiter.GetAttemptCount("10.0.0.2"));
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryRegisterAttempt_BlankKey_Throws(string? key)
{
var limiter = new BundleUnlockRateLimiter();
Assert.ThrowsAny<ArgumentException>(
() => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void TryRegisterAttempt_NonPositiveLimit_Throws(int limit)
{
var limiter = new BundleUnlockRateLimiter();
Assert.Throws<ArgumentOutOfRangeException>(
() => limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: limit));
}
}
@@ -8,7 +8,10 @@ namespace ScadaLink.Transport.Tests.Serialization;
public sealed class BundleSerializerTests
{
private const int TestIterations = 10_000;
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum).
// Using the floor keeps the suite fast while passing EncryptionMetadata
// validation.
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
private static BundleContentDto SampleContent() => new(
TemplateFolders: new[] { new TemplateFolderDto("Root", ParentName: null, SortOrder: 0) },