583 lines
30 KiB
Markdown
583 lines
30 KiB
Markdown
# Code Review — InboundAPI
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| Module | `src/ScadaLink.InboundAPI` |
|
|
| Design doc | `docs/requirements/Component-InboundAPI.md` |
|
|
| Status | Reviewed |
|
|
| Last reviewed | 2026-05-16 |
|
|
| Reviewer | claude-agent |
|
|
| Commit reviewed | `9c60592` |
|
|
| Open findings | 0 |
|
|
|
|
## Summary
|
|
|
|
The InboundAPI module is small (8 source files) and the happy-path flow — extract
|
|
key, validate, deserialize parameters, execute script, serialize result — is clean
|
|
and readable. However the review surfaced several real problems concentrated in two
|
|
themes: **concurrency** and **security**. The `InboundScriptExecutor` is a singleton
|
|
that mutates a plain `Dictionary` from concurrent ASP.NET request threads with no
|
|
synchronization, which can corrupt the handler cache or crash the process under load.
|
|
On the security side, API-key comparison is a non-constant-time database string
|
|
match (timing oracle), compiled scripts run with no enforcement of the documented
|
|
script trust model (forbidden APIs such as `System.IO`/`Process`/`Reflection` are
|
|
fully reachable), there is no request-body size limit, and the executor's catch-all
|
|
swallows `OperationCanceledException` from genuine client disconnects as a "timeout".
|
|
Design-doc adherence is also incomplete: the `Database.Connection()` script API
|
|
described in the design doc is entirely absent from `InboundScriptContext`, and the
|
|
endpoint never enforces that the API is central-only. Testing covers the validators
|
|
well but there is no coverage of the HTTP endpoint, concurrency, or recompilation.
|
|
None of the findings are data-loss-class, but the concurrency and trust-model issues
|
|
are High severity and should be addressed before production use.
|
|
|
|
## Checklist coverage
|
|
|
|
| # | Category | Examined | Notes |
|
|
|---|----------|----------|-------|
|
|
| 1 | Correctness & logic bugs | ☑ | `CoerceValue` returns `null` for legitimately-null/`String` values indistinguishably; parameter-definition edge cases noted. |
|
|
| 2 | Akka.NET conventions | ☑ | Module is ASP.NET-hosted, no actors of its own; routes to actors via `CommunicationService`. No correlation-ID issues — IDs are set in `RouteHelper`. |
|
|
| 3 | Concurrency & thread safety | ☑ | Singleton `InboundScriptExecutor` mutates a non-thread-safe `Dictionary` from concurrent request threads — see InboundAPI-001/002. |
|
|
| 4 | Error handling & resilience | ☑ | Catch-all conflates client cancellation with timeout (InboundAPI-004); compilation-failure path repeats work on every request (InboundAPI-009). |
|
|
| 5 | Security | ☑ | Non-constant-time key comparison, no trust-model enforcement, no body-size limit, missing-method enumeration oracle — see InboundAPI-003/005/006/011. |
|
|
| 6 | Performance & resource management | ☑ | Up to 3 separate DB round-trips per request in `ApiKeyValidator`; uncapped lazy recompilation. |
|
|
| 7 | Design-document adherence | ☑ | `Database.Connection()` script API missing; central-only hosting not enforced; lazy-compile diverges from "compiled at startup". |
|
|
| 8 | Code organization & conventions | ☑ | `ParameterDefinition` is an API-shaped POCO declared in the component project rather than Commons; otherwise conventions followed. |
|
|
| 9 | Testing coverage | ☑ | Good unit coverage of the two validators; no endpoint, concurrency, recompilation, or timeout-vs-cancel tests. |
|
|
| 10 | Documentation & comments | ☑ | `ApiKeyValidationResult.NotFound` XML/name says "NotFound" but returns HTTP 400 — misleading (InboundAPI-013). |
|
|
|
|
## Findings
|
|
|
|
### InboundAPI-001 — Singleton script handler cache mutated without synchronization
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | High |
|
|
| Category | Concurrency & thread safety |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:17`, `:32`, `:40`, `:89`, `:123-128` |
|
|
|
|
**Description**
|
|
|
|
`InboundScriptExecutor` is registered as a singleton (`ServiceCollectionExtensions.cs:11`)
|
|
and its handler cache is a plain `Dictionary<string, Func<...>>` (`InboundScriptExecutor.cs:17`).
|
|
`RegisterHandler`, `RemoveHandler`, `CompileAndRegister`, and the lazy-compile path in
|
|
`ExecuteAsync` all read and write this dictionary with no lock. ASP.NET serves inbound
|
|
API requests on concurrent thread-pool threads, so two requests for an as-yet-uncompiled
|
|
method (or a request racing a CLI-triggered `CompileAndRegister`) can mutate the
|
|
dictionary concurrently. `Dictionary` is explicitly not safe for concurrent
|
|
read/write — this can corrupt internal buckets, throw `InvalidOperationException`,
|
|
or return a torn/`null` handler, crashing the request or the process.
|
|
|
|
**Recommendation**
|
|
|
|
Replace the `Dictionary` with a `ConcurrentDictionary<string, Func<...>>`, or guard all
|
|
access with a lock. For the lazy-compile path use `GetOrAdd` so concurrent first-callers
|
|
compile at most once.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `<pending>`): replaced the plain `Dictionary` handler
|
|
cache with a `ConcurrentDictionary`; `RemoveHandler` now uses `TryRemove`; the
|
|
lazy-compile path in `ExecuteAsync` compiles outside the cache and inserts atomically
|
|
via `GetOrAdd` so concurrent first-callers share one handler. Regression tests
|
|
`ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache` and
|
|
`ConcurrentRegisterAndExecute_DoesNotThrow` added.
|
|
|
|
### InboundAPI-002 — Lazy compilation is a check-then-act race with no atomicity
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Medium — re-triaged: already fixed by the InboundAPI-001 fix; verified and closed |
|
|
| Category | Concurrency & thread safety |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:152-161` |
|
|
|
|
**Description**
|
|
|
|
`ExecuteAsync` does `if (!_scriptHandlers.TryGetValue(...)) { CompileAndRegister(method); handler = _scriptHandlers[method.Name]; }`.
|
|
Even setting aside the unsynchronized dictionary (InboundAPI-001), this is a
|
|
check-then-act sequence: between `TryGetValue` failing and the re-read on line 128,
|
|
another thread could `RemoveHandler` the entry, causing the indexer on line 128 to
|
|
throw `KeyNotFoundException` — an unhandled-in-context exception that is then caught
|
|
only by the broad catch on line 143 and reported to the caller as "Internal script
|
|
error". Multiple concurrent first-callers will also each compile the same script
|
|
redundantly (wasted Roslyn work).
|
|
|
|
**Recommendation**
|
|
|
|
Make compile-and-fetch a single atomic operation (`ConcurrentDictionary.GetOrAdd`
|
|
with a lazily-evaluated factory, or a per-method lock), and have `CompileAndRegister`
|
|
return the handler it produced rather than requiring a separate dictionary read.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `pending`): re-triage — verified against the current
|
|
source, this finding was **already fixed** by the InboundAPI-001 fix. The
|
|
`InboundScriptExecutor.cs:152-161` lazy-compile path no longer does check-then-act
|
|
re-read: `Compile(method)` runs unconditionally (it never reads the cache) and the
|
|
result is published via the atomic `_scriptHandlers.GetOrAdd(method.Name, compiled)`.
|
|
There is no separate dictionary indexer read, so the `KeyNotFoundException` race the
|
|
finding describes cannot occur, and concurrent first-callers all share the single
|
|
handler that `GetOrAdd` keeps. Regression test
|
|
`LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound` added (asserts a concurrent
|
|
`RemoveHandler` storm against lazy-compiling callers never yields the catch-all
|
|
"Internal script error"); it passes against the current code, confirming the fix.
|
|
|
|
### InboundAPI-003 — API key compared with non-constant-time string equality
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | High |
|
|
| Category | Security |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:22-23`, consumed by `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:33` |
|
|
|
|
**Description**
|
|
|
|
API-key authentication resolves the key with
|
|
`FirstOrDefaultAsync(k => k.KeyValue == keyValue)` — an ordinary equality match
|
|
translated to a SQL `WHERE KeyValue = @p` comparison. The secret is matched with
|
|
ordinary (early-exit) string/SQL comparison rather than a constant-time comparison,
|
|
which is a classic timing side-channel for secret material. Combined with the design's
|
|
explicit "no rate limiting" decision, an attacker with network access to the central
|
|
API can mount a timing attack to recover valid keys. The API key is the *sole*
|
|
credential for the inbound API, so this is the primary authentication path.
|
|
|
|
**Recommendation**
|
|
|
|
Look the key up by a non-secret indexed identifier (e.g. a key prefix/id) or fetch
|
|
candidate rows, then verify the secret in-process using
|
|
`CryptographicOperations.FixedTimeEquals` over the UTF-8 bytes. Preferably store only
|
|
a salted hash of the key value and compare hashes. Avoid leaking secret-length and
|
|
match-position timing.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `<pending>`): `ApiKeyValidator` no longer calls the
|
|
secret-equality lookup `GetApiKeyByValueAsync` (the SQL `WHERE KeyValue = @secret`
|
|
timing oracle). It now fetches all keys via `GetAllApiKeysAsync` and matches the
|
|
secret in-process with `CryptographicOperations.FixedTimeEquals` over the UTF-8 bytes,
|
|
scanning every candidate so neither match position nor secret length is observable.
|
|
Regression tests `ValidateAsync_DoesNotUseSecretEqualityLookup`,
|
|
`ValidateAsync_WrongKey_ConstantTimePath_Returns401`, and
|
|
`ValidateAsync_KeyOfDifferentLength_Returns401` added. Note: the timing-oracle method
|
|
`GetApiKeyByValueAsync` remains on `IInboundApiRepository` (it is outside this module);
|
|
removing it from the repository is left as separate follow-up since the validator no
|
|
longer depends on it.
|
|
|
|
### InboundAPI-004 — Client disconnect is misreported as a script timeout
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Medium |
|
|
| Category | Error handling & resilience |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:117-141` |
|
|
|
|
**Description**
|
|
|
|
`ExecuteAsync` creates a linked CTS from `httpContext.RequestAborted` and the method
|
|
timeout, then catches `OperationCanceledException` and unconditionally returns
|
|
"Script execution timed out". When the *client* aborts the request (`RequestAborted`
|
|
fires), the same exception type is thrown, so a normal client disconnect is logged as
|
|
a timeout (`_logger.LogWarning("Script execution timed out ...")`) and an attempt is
|
|
made to write a 500 timeout body to an already-gone connection. This pollutes the
|
|
failure log (which the design says is reserved for genuine script errors) and obscures
|
|
real timeout incidents.
|
|
|
|
**Recommendation**
|
|
|
|
Distinguish the two cancellation sources: if `cancellationToken` (the request token)
|
|
is cancelled, treat it as a client abort — do not log a timeout and do not attempt to
|
|
write a response. Only when the timeout CTS fired should the result be "timed out".
|
|
Check `cts.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested`
|
|
or use a dedicated timeout `CancellationTokenSource` so the two are separable.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `pending`): `ExecuteAsync` now uses a dedicated timeout
|
|
`CancellationTokenSource` (`new CancellationTokenSource(timeout)`) linked with the
|
|
request-abort token, so the two cancellation sources are separable. The
|
|
`OperationCanceledException` handler reports "Script execution timed out" (and logs a
|
|
warning) **only** when the timeout CTS fired and the request token did not; a client
|
|
abort instead returns "Request cancelled by client" and logs at Debug — the failure
|
|
log stays reserved for genuine script-execution timeouts. `HandleInboundApiRequest`
|
|
additionally short-circuits with `Results.Empty` (no warning log, no 500 body write)
|
|
when `RequestAborted` is cancelled, since the connection is already gone. Regression
|
|
tests `ClientDisconnect_IsNotReportedAsTimeout` and `GenuineTimeout_StillReportedAsTimeout`
|
|
added.
|
|
|
|
### InboundAPI-005 — Compiled API scripts run with no script-trust-model enforcement
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | High |
|
|
| Category | Security |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:56-93` |
|
|
|
|
**Description**
|
|
|
|
CLAUDE.md's Akka.NET conventions state the script trust model forbids `System.IO`,
|
|
`Process`, `Threading`, `Reflection`, and raw network access. `CompileAndRegister`
|
|
compiles arbitrary C# with `CSharpScript.Create` and only restricts the *default
|
|
imports* (`WithImports("System", ...)`). Imports are a convenience, not a sandbox — a
|
|
script can still fully-qualify any type (`System.IO.File.Delete(...)`,
|
|
`System.Diagnostics.Process.Start(...)`, `System.Reflection`, raw `Socket`) because
|
|
the core framework assemblies are referenced and Roslyn scripting performs no API
|
|
allow/deny-listing. Inbound API scripts execute on the central node with the host
|
|
process's privileges, so a malicious or buggy method definition has full host access.
|
|
Note the Design role authors these scripts (less trusted than Admin), making
|
|
enforcement material.
|
|
|
|
**Recommendation**
|
|
|
|
Add a compile-time analyzer/`SyntaxWalker` (as the Site Runtime does for instance
|
|
scripts) that rejects forbidden namespaces/types before registering a handler, and/or
|
|
run scripts under a constrained boundary. At minimum, share the Site Runtime's
|
|
forbidden-API checker so the trust model is enforced consistently. Reject the method
|
|
(and log) when a violation is found instead of registering it.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `<pending>`): added `ForbiddenApiChecker`, a Roslyn
|
|
`CSharpSyntaxWalker` that statically rejects scripts referencing forbidden namespaces
|
|
(`System.IO`, `System.Diagnostics`, `System.Threading` except `Tasks`,
|
|
`System.Reflection`, `System.Net`, `System.Runtime.InteropServices`, `Microsoft.Win32`)
|
|
whether reached via a `using` directive or a fully-qualified name. `CompileAndRegister`
|
|
now runs the check before Roslyn compilation and refuses to register (and logs) a
|
|
violating method; `ExecuteAsync`'s lazy-compile path is gated by the same check.
|
|
Regression tests `CompileAndRegister_ForbiddenApi_RejectsScript` (theory),
|
|
`ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure`, and
|
|
`CompileAndRegister_PermittedScript_StillRegisters` added.
|
|
|
|
### InboundAPI-006 — No request body size limit on the inbound endpoint
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Medium |
|
|
| Category | Security |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:54-62` |
|
|
|
|
**Description**
|
|
|
|
`HandleInboundApiRequest` calls `JsonDocument.ParseAsync(httpContext.Request.Body, ...)`
|
|
with no explicit body-size cap and no `[RequestSizeLimit]`/endpoint metadata. Although
|
|
Kestrel has a default max request body size, this endpoint accepts arbitrary JSON from
|
|
external systems, fully buffers it into a `JsonDocument`, and then `Clone()`s the
|
|
root element (`:61`) which materializes the entire document on the heap. With no rate
|
|
limiting (a deliberate design choice) a single caller can drive large allocations.
|
|
Deep/wide JSON also makes the `CoerceValue` `object`/`list` deserialization
|
|
(`ParameterValidator.cs:113,117`) expensive.
|
|
|
|
**Recommendation**
|
|
|
|
Set an explicit, modest body-size limit on the endpoint
|
|
(`.WithMetadata(new RequestSizeLimitAttribute(...))` or
|
|
`IHttpMaxRequestBodySizeFeature`) and consider a `JsonDocumentOptions` `MaxDepth`.
|
|
Reject oversized bodies with 413 before buffering.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `pending`): added `InboundApiEndpointFilter`, an
|
|
`IEndpointFilter` applied to `POST /api/{methodName}` via `.AddEndpointFilter<>()`.
|
|
It rejects requests whose declared `Content-Length` exceeds `InboundApiOptions.
|
|
MaxRequestBodyBytes` (default 1 MiB) with HTTP 413 *before* the handler buffers the
|
|
body into a `JsonDocument`, and also lowers the per-request `IHttpMaxRequestBodySizeFeature`
|
|
cap so a chunked/unknown-length stream is cut off by Kestrel while being read. The
|
|
limit is configurable via the bound `ScadaLink:InboundApi` options section. Regression
|
|
tests `OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler`, `BodyAtLimit_RunsHandler`,
|
|
and `FilterCapsMaxRequestBodySizeFeature` added.
|
|
|
|
### InboundAPI-007 — `Database.Connection()` script API from the design doc is not implemented
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Medium |
|
|
| Category | Design-document adherence |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:188-203` |
|
|
|
|
**Description**
|
|
|
|
`Component-InboundAPI.md` ("Script Runtime API -> Database Access") specifies
|
|
`Database.Connection("connectionName")` as an available script capability for
|
|
querying the configuration/machine-data databases. `InboundScriptContext` exposes only
|
|
`Parameters`, `Route`, and `CancellationToken` — there is no `Database` member. Any
|
|
method script that follows the documented API will fail to compile. Either the code
|
|
is incomplete or the design doc is stale; the two must be reconciled.
|
|
|
|
**Recommendation**
|
|
|
|
If database access is in scope, add a `Database` property to `InboundScriptContext`
|
|
backed by a connection-factory service. If it is not, remove the "Database Access"
|
|
section from `Component-InboundAPI.md` so the design doc stops advertising an absent
|
|
API.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `<pending>`). The drift was confirmed real:
|
|
`InboundScriptContext` (`InboundScriptExecutor.cs:188-203`) exposes only
|
|
`Parameters`, `Route`, and `CancellationToken` — there is no `Database` member,
|
|
so a method script following the documented `Database.Connection("name")` API
|
|
would fail to compile. Resolution direction: the design doc is stale, not the
|
|
code. Implementing `Database.Connection()` would hand inbound API scripts a
|
|
*raw* MS SQL client, in direct tension with the ScadaLink script trust model
|
|
(scripts are forbidden `System.IO`, raw network, etc.; `ForbiddenApiChecker`
|
|
statically enforces this). Rather than carve a hole in the trust model, the
|
|
"Database Access" section was removed from `docs/requirements/Component-InboundAPI.md`
|
|
and replaced with an explicit "No direct database access" note explaining that
|
|
scripts interact only through the curated `Route`/`Parameters` surfaces, and
|
|
that any future data access must go behind a dedicated scoped helper added as an
|
|
explicit design change. Code and doc now agree; no code or test change required.
|
|
|
|
### InboundAPI-008 — Inbound API endpoint not restricted to the active central node
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Medium |
|
|
| Category | Design-document adherence |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:19-23`, `src/ScadaLink.Host/Program.cs:149` |
|
|
|
|
**Description**
|
|
|
|
The design states the Inbound API is "Central cluster only (active node)" and "fails
|
|
over with it". `MapInboundAPI` registers `POST /api/{methodName}` unconditionally, and
|
|
`Program.cs` maps it inside the central-role branch but with no active-node gating —
|
|
unlike `/health/active` which has an `active-node` predicate. A standby central node
|
|
will happily serve inbound API calls, executing scripts and `Route.To()` calls from a
|
|
non-leader, which can race the active node or run against stale singleton state.
|
|
|
|
**Recommendation**
|
|
|
|
Gate the endpoint on active-node status (reuse the cluster `active-node` health check
|
|
or a leader-state check) and return 503 on the standby, so Traefik/clients only reach
|
|
the live node — consistent with how the Management API and `/health/active` are
|
|
treated.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `pending`): introduced `IActiveNodeGate`, an abstraction
|
|
the inbound API uses to ask whether this node is the active (cluster-leader) central
|
|
node. The new `InboundApiEndpointFilter` (applied to `POST /api/{methodName}`)
|
|
consults the gate and short-circuits a standby node with HTTP 503 before any
|
|
auth/script work, so Traefik/clients only reach the live node — consistent with
|
|
`/health/active`. The gate is resolved optionally: when no implementation is
|
|
registered (non-clustered host / tests) the endpoint defaults to "allow", preserving
|
|
prior behaviour. Regression tests `StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler`,
|
|
`ActiveNode_PassesGate_RunsHandler`, and `NoGateRegistered_PassesGate_RunsHandler`
|
|
added. **Follow-up (outside this module's scope):** `ScadaLink.Host` should register
|
|
an `IActiveNodeGate` implementation backed by `ActiveNodeHealthCheck` /
|
|
`Cluster.State.Leader` in the central-role branch of `Program.cs` so the gate is
|
|
actually enforced in production; until then the endpoint defaults to "allow".
|
|
|
|
### InboundAPI-009 — Failed compilation is retried on every subsequent request
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Low |
|
|
| Category | Performance & resource management |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:123-128` |
|
|
|
|
**Description**
|
|
|
|
When a method's script fails to compile, `CompileAndRegister` returns `false` and
|
|
nothing is stored in `_scriptHandlers`. Every subsequent call to that method re-enters
|
|
the lazy-compile branch and recompiles the broken script via Roslyn from scratch.
|
|
Roslyn compilation is expensive; a single broken method definition repeatedly invoked
|
|
by an external caller (no rate limiting) becomes a CPU amplification vector.
|
|
|
|
**Recommendation**
|
|
|
|
Cache the compilation *failure* (e.g. store a sentinel handler that immediately
|
|
returns the compile error, or keep a `HashSet` of known-bad method names with the
|
|
diagnostic) so a broken script is compiled at most once until the definition is
|
|
updated via `CompileAndRegister`.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit pending): confirmed the root cause — a failed `Compile`
|
|
stored nothing in `_scriptHandlers`, so every subsequent request re-entered the
|
|
lazy-compile branch and re-ran Roslyn. Added a `_knownBadMethods` `ConcurrentDictionary`
|
|
of method names whose compilation failed; `ExecuteAsync`'s lazy-compile path
|
|
short-circuits before Roslyn when the method is already known-bad, and records the
|
|
failure on a fresh failed compile. `CompileAndRegister` also records failures and
|
|
clears the record on a successful (re)compile, so a fixed method definition is
|
|
re-evaluated. Regression tests `FailedCompilation_IsNotRetriedOnEveryRequest`
|
|
(asserts the compile-failure log fires exactly once across 5 requests) and
|
|
`FailedCompilation_RecompilesAfterCompileAndRegisterCalledAgain` added.
|
|
|
|
### InboundAPI-010 — `ParameterValidator` ignores extra body fields and cannot validate Object/List element types
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Low |
|
|
| Category | Correctness & logic bugs |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/ParameterValidator.cs:64-90`, `:112-118` |
|
|
|
|
**Description**
|
|
|
|
Two related correctness gaps: (1) The validator iterates only over *defined*
|
|
parameters; any extra top-level fields in the request body are silently ignored
|
|
rather than reported, so callers get no feedback on typo'd parameter names. (2) For
|
|
`Object` and `List` types the validator only checks the JSON *kind* (`Object`/`Array`)
|
|
and then blindly `JsonSerializer.Deserialize`s the raw text — the design's extended
|
|
type system describes Objects as "named structure with typed fields" and Lists as
|
|
collections "of objects or primitive types", but no field-level or element-level type
|
|
validation is performed. Invalid nested structures pass validation and surface only
|
|
as runtime script errors.
|
|
|
|
**Recommendation**
|
|
|
|
Optionally warn/400 on unexpected body fields. For the extended types, either parse a
|
|
richer `ParameterDefinition` (with nested field definitions / element type) and
|
|
validate recursively, or document explicitly that Object/List are validated only for
|
|
shape — and update the design doc to match.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit pending): both gaps addressed along the lines the
|
|
recommendation offers. (1) `ParameterValidator.Validate` now enumerates the request
|
|
body's top-level fields and returns an Invalid result naming any field that does not
|
|
match a defined parameter (`"Unexpected parameter(s): ..."`), so a typo'd parameter
|
|
name is reported instead of silently ignored. (2) For `Object`/`List`, recursive
|
|
field/element-level type validation is **deliberately not** added — it requires a
|
|
richer nested `ParameterDefinition` schema, a design decision; instead the
|
|
shape-only behaviour is now explicitly documented in the `CoerceValue` XML comment so
|
|
the code's contract is unambiguous. Re-triage note: the design doc
|
|
(`Component-InboundAPI.md` line 43) lists only Boolean/Integer/Float/String as method
|
|
parameter types — the Object/List extended types are a CLAUDE.md decision; reconciling
|
|
the design doc is out of this module's editable scope and left as a doc-owner
|
|
follow-up. Regression tests `UnexpectedBodyField_ReturnsInvalid` and
|
|
`OnlyDefinedFields_StillValid` added.
|
|
|
|
### InboundAPI-011 — Method-existence check leaks to unapproved callers (enumeration oracle)
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Low |
|
|
| Category | Security |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:39-52` |
|
|
|
|
**Description**
|
|
|
|
`ValidateAsync` returns 400 `Method '{methodName}' not found` when the method does not
|
|
exist, but 403 `API key not approved for this method` when it exists but the key is
|
|
not approved. A caller holding any valid enabled key can therefore enumerate which
|
|
method names exist on the central API by observing 400-vs-403 responses. The error
|
|
message also echoes the caller-supplied `methodName` back verbatim into the JSON
|
|
response (`EndpointExtensions.cs:47`), a minor reflected-input concern.
|
|
|
|
**Recommendation**
|
|
|
|
Return an indistinguishable response (e.g. 403/404) for both "method not found" and
|
|
"key not approved" so existence is not observable to unapproved callers. Avoid echoing
|
|
raw caller input in error bodies, or sanitize it.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit pending): confirmed — `ValidateAsync` returned 400
|
|
`Method '{methodName}' not found` for a missing method but 403
|
|
`API key not approved for this method` for an existing-but-unapproved one, an
|
|
enumeration oracle, and echoed the caller-supplied method name verbatim. Both cases
|
|
now return an identical response: HTTP 403 with the single shared message
|
|
`API key not approved for this method` (a `NotApprovedMessage` constant); the
|
|
method name is no longer interpolated into any error body, removing both the
|
|
existence oracle and the reflected-input concern. Regression tests
|
|
`ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved` and
|
|
`ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName` added; the pre-existing
|
|
`ValidKey_MethodNotFound_Returns400` test was updated to assert the new
|
|
indistinguishable contract.
|
|
|
|
### InboundAPI-012 — `ParameterDefinition` POCO declared in the component project, not Commons
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Low |
|
|
| Category | Code organization & conventions |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/ParameterValidator.cs:128-133` |
|
|
|
|
**Description**
|
|
|
|
`ParameterDefinition` is a persistence-/contract-shaped POCO: it is the deserialized
|
|
form of `ApiMethod.ParameterDefinitions` (a column in the configuration database) and
|
|
describes the public API contract. CLAUDE.md's code-organization rules place
|
|
persistence-ignorant entity/contract types in `ScadaLink.Commons`. Defining it inside
|
|
the InboundAPI project means any other component that needs to read or produce method
|
|
parameter definitions (e.g. Central UI's method editor, CLI, Management Service)
|
|
cannot share the type and will duplicate it.
|
|
|
|
**Recommendation**
|
|
|
|
Move `ParameterDefinition` (and a matching return-definition type, if added) to
|
|
`ScadaLink.Commons` under the InboundApi entity/types namespace so it is shared by all
|
|
components that work with method definitions.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit `<pending>`): root cause confirmed against the source —
|
|
`ParameterDefinition` was a persistence-ignorant, API-contract-shaped POCO (the
|
|
deserialized form of the `ApiMethod.ParameterDefinitions` configuration-database
|
|
column) declared inside the component project, contrary to CLAUDE.md's
|
|
code-organization rule that such shared contract types live in `ScadaLink.Commons`.
|
|
The type was moved to `src/ScadaLink.Commons/Types/InboundApi/ParameterDefinition.cs`
|
|
(namespace `ScadaLink.Commons.Types.InboundApi`) — placed under `Types/` with an
|
|
`InboundApi` domain subfolder, matching the existing `Types/Scripts/` precedent, since
|
|
the column itself is the persisted form and this type is its deserialized contract
|
|
shape (not an EF-mapped entity). It remains a pure POCO with no EF attributes and no
|
|
behaviour. `ParameterValidator` now imports the moved type via a `using
|
|
ScadaLink.Commons.Types.InboundApi;` directive; a tree-wide search confirmed
|
|
`ParameterValidator.cs` was the type's only declaration and only direct consumer (all
|
|
other `ParameterDefinition*` matches are the unrelated `ParameterDefinitions` string
|
|
property). No return-definition type exists in the codebase — only a `ReturnDefinition`
|
|
string column — so none was invented. No behavioural change, so no new runtime
|
|
regression test: this is a compile-level type move, and the existing 52
|
|
`ScadaLink.InboundAPI.Tests` (including the `ParameterValidator` suite) act as the
|
|
regression guard. `dotnet test` for `ScadaLink.InboundAPI.Tests` (52 passed) and
|
|
`ScadaLink.Commons.Tests` (226 passed) are green; `dotnet build ScadaLink.slnx`
|
|
succeeds with 0 warnings / 0 errors.
|
|
|
|
### InboundAPI-013 — `ApiKeyValidationResult.NotFound` factory returns HTTP 400, contradicting its name
|
|
|
|
| | |
|
|
|--|--|
|
|
| Severity | Low |
|
|
| Category | Documentation & comments |
|
|
| Status | Resolved |
|
|
| Location | `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:78-79` |
|
|
|
|
**Description**
|
|
|
|
The static factory is named `NotFound` and is used for the "method not found" case,
|
|
but it builds a result with `StatusCode = 400` (Bad Request), not 404. The name
|
|
strongly implies 404 and will mislead future maintainers; `EndpointExtensions`
|
|
faithfully propagates whatever status code the factory sets, so the misnaming directly
|
|
affects the wire contract.
|
|
|
|
**Recommendation**
|
|
|
|
Rename the factory to match its behaviour (e.g. `BadRequest`) or change the status
|
|
code to 404 if that is the intended contract — and document the chosen "method not
|
|
found" status in `Component-InboundAPI.md`'s Error Handling section, which currently
|
|
does not list it.
|
|
|
|
**Resolution**
|
|
|
|
Resolved 2026-05-16 (commit pending): the misnamed `NotFound` factory (which built a
|
|
`StatusCode = 400` result) was the only producer of the "method not found" result,
|
|
and the InboundAPI-011 fix made "method not found" return 403 via the existing
|
|
`Forbidden` factory instead. The misleading `NotFound` factory is therefore now
|
|
**removed entirely** — it has no remaining callers in or out of the module
|
|
(`ApiKeyValidationResult` is InboundAPI-internal), eliminating the name/behaviour
|
|
contradiction. No separate regression test is needed: the InboundAPI-011 tests cover
|
|
the new method-not-found status, and removing dead code cannot regress. Doc-owner
|
|
follow-up: `Component-InboundAPI.md`'s Error Handling section still does not list a
|
|
"method not found" status; it should note that it is reported as 403 (indistinguishable
|
|
from "key not approved"), but that doc edit is outside this module's editable scope.
|