fix(validation): close Theme 3 — 11 input-validation / unbounded-input findings
Each finding is a focused validation guard or upper bound at a trust boundary.
Highlights:
- Commons-015: EncryptionMetadata ctor now validates Algorithm (AES-256-GCM
only), Kdf (PBKDF2-SHA256 only), Iterations ([100k, 10M]), non-null Salt/IV.
- Transport-004: new BundleUnlockRateLimiter (sliding-window, per-key,
singleton) wired into BundleImporter.LoadAsync; over-budget callers see
BundleUnlockRateLimitedException. Per-bundle 3-strike + per-window cap.
- ESG-022: ExternalSystemClient.InvokeHttpAsync allow-lists the documented
GET/POST/PUT/PATCH/DELETE set (case-insensitive); unknown verbs throw.
- SEL-015: SiteEventLogger queue now bounded (10k cap, DropOldest); dropped
events fault their Task and increment FailedWriteCount so the drop is
observable instead of an unbounded memory growth.
- SEL-017: EventLogQueryService clamps caller-supplied PageSize to a new
MaxQueryPageSize cap (default 500) so int.MaxValue can't OOM the host.
- SEL-020: LogEventAsync rejects severities outside {Info, Warning, Error}
(matches SQLite BINARY-collation query filter).
- InboundAPI-020: ContentType "json" check now case-insensitive
(application/JSON no longer slips through as not-json).
- InboundAPI-024: _knownBadMethods capped at 1000 entries (drops new entries
once full); per-request DB lookup remains the correctness path.
- SR-025: HandleSetStaticAttribute validates the attribute name against the
deployed config; unknown names now return Success=false instead of
leaking orphan override rows into the SQLite store.
- TE-021: MoveTemplateAsync runs the sibling-name-collision check at the
destination, mirroring TemplateFolderService.MoveFolderAsync.
- TE-022: LockEnforcer's once-locked-stays-locked rule now also covers
LockedInDerived (was previously only IsLocked).
New regression tests across 8 test projects (EncryptionMetadata, rate
limiter, ESG client allow-list, SEL bounded channel / PageSize clamp /
severity validation, InboundAPI ContentType + bad-methods cap, SiteRT
unknown-attribute, TemplateEngine MoveTemplate + LockedInDerived).
Build clean; affected suites all green. README regenerated: 93 open (was 104).
Note: a separate manual re-run was needed for the SiteEventLogging hunk
because its initial subagent's source edits never landed on disk despite
reporting success (file-collision-style failure mode).
This commit is contained in:
@@ -8,7 +8,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:
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
130–142. 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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user