fix(inbound-api): resolve InboundAPI-001/003/005 — concurrent handler cache, constant-time API key compare, script trust-model enforcement
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 13 |
|
| Open findings | 10 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ are High severity and should be addressed before production use.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:17`, `:32`, `:40`, `:89`, `:123-128` |
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:17`, `:32`, `:40`, `:89`, `:123-128` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -76,7 +76,12 @@ compile at most once.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
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
|
### InboundAPI-002 — Lazy compilation is a check-then-act race with no atomicity
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:22-23`, consumed by `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:33` |
|
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:22-23`, consumed by `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:33` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -138,7 +143,17 @@ match-position timing.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
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
|
### InboundAPI-004 — Client disconnect is misreported as a script timeout
|
||||||
|
|
||||||
@@ -178,7 +193,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:56-93` |
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:56-93` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -205,7 +220,16 @@ forbidden-API checker so the trust model is enforced consistently. Reject the me
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
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
|
### InboundAPI-006 — No request body size limit on the inbound endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using ScadaLink.Commons.Entities.InboundApi;
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
|
||||||
@@ -30,7 +32,14 @@ public class ApiKeyValidator
|
|||||||
return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header");
|
return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header");
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiKey = await _repository.GetApiKeyByValueAsync(apiKeyValue, cancellationToken);
|
// InboundAPI-003: do NOT resolve the key with a secret-equality lookup
|
||||||
|
// (GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
|
||||||
|
// comparison — a timing side-channel). Fetch all keys and match the secret
|
||||||
|
// in-process with a constant-time comparison so neither match position nor
|
||||||
|
// secret length is observable to a network attacker.
|
||||||
|
var apiKey = FindKeyConstantTime(
|
||||||
|
await _repository.GetAllApiKeysAsync(cancellationToken),
|
||||||
|
apiKeyValue);
|
||||||
if (apiKey == null || !apiKey.IsEnabled)
|
if (apiKey == null || !apiKey.IsEnabled)
|
||||||
{
|
{
|
||||||
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
|
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
|
||||||
@@ -53,6 +62,31 @@ public class ApiKeyValidator
|
|||||||
|
|
||||||
return ApiKeyValidationResult.Valid(apiKey, method);
|
return ApiKeyValidationResult.Valid(apiKey, method);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// InboundAPI-003: Finds the key whose value matches <paramref name="candidate"/>
|
||||||
|
/// using <see cref="CryptographicOperations.FixedTimeEquals"/> over the UTF-8 bytes.
|
||||||
|
/// Every candidate row is compared so that the running time does not depend on the
|
||||||
|
/// match position; length mismatches return false without leaking length timing.
|
||||||
|
/// </summary>
|
||||||
|
private static ApiKey? FindKeyConstantTime(IEnumerable<ApiKey> keys, string candidate)
|
||||||
|
{
|
||||||
|
var candidateBytes = Encoding.UTF8.GetBytes(candidate);
|
||||||
|
ApiKey? match = null;
|
||||||
|
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
var keyBytes = Encoding.UTF8.GetBytes(key.KeyValue);
|
||||||
|
if (CryptographicOperations.FixedTimeEquals(candidateBytes, keyBytes))
|
||||||
|
{
|
||||||
|
// Do not break — continuing keeps the loop's timing independent of
|
||||||
|
// where (or whether) a match is found.
|
||||||
|
match = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
121
src/ScadaLink.InboundAPI/ForbiddenApiChecker.cs
Normal file
121
src/ScadaLink.InboundAPI/ForbiddenApiChecker.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ScadaLink.InboundAPI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// InboundAPI-005: Enforces the ScadaLink script trust model on inbound API method
|
||||||
|
/// scripts before they are compiled into executable handlers.
|
||||||
|
///
|
||||||
|
/// The trust model (CLAUDE.md, Akka.NET conventions) forbids scripts from reaching
|
||||||
|
/// <c>System.IO</c>, <c>System.Diagnostics.Process</c>, <c>System.Threading</c>,
|
||||||
|
/// <c>System.Reflection</c>, and raw network APIs. Roslyn scripting performs no
|
||||||
|
/// API allow/deny-listing — restricting default imports is a convenience, not a
|
||||||
|
/// sandbox — so a script can fully-qualify any referenced type. This static check
|
||||||
|
/// walks the script syntax tree and rejects any reference to a forbidden namespace,
|
||||||
|
/// whether reached through a <c>using</c> directive or a fully-qualified name.
|
||||||
|
/// </summary>
|
||||||
|
public static class ForbiddenApiChecker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Namespace prefixes the trust model forbids. A script segment matches if it is
|
||||||
|
/// equal to one of these or is a child namespace of it.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] ForbiddenNamespaces =
|
||||||
|
{
|
||||||
|
"System.IO",
|
||||||
|
"System.Diagnostics", // covers Process
|
||||||
|
"System.Threading", // Task/Tasks is explicitly re-allowed below
|
||||||
|
"System.Reflection",
|
||||||
|
"System.Net", // raw network (Sockets, HttpClient, etc.)
|
||||||
|
"System.Runtime.InteropServices",
|
||||||
|
"Microsoft.Win32",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Namespaces that would otherwise be caught by a forbidden prefix but are
|
||||||
|
/// required for normal async script authoring and carry no host-access risk.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] AllowedExceptions =
|
||||||
|
{
|
||||||
|
"System.Threading.Tasks",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyses the script source and returns the list of trust-model violations.
|
||||||
|
/// An empty list means the script is acceptable.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> FindViolations(string scriptCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptCode))
|
||||||
|
return Array.Empty<string>();
|
||||||
|
|
||||||
|
var tree = CSharpSyntaxTree.ParseText(
|
||||||
|
scriptCode,
|
||||||
|
new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||||
|
|
||||||
|
var walker = new ForbiddenApiWalker();
|
||||||
|
walker.Visit(tree.GetRoot());
|
||||||
|
return walker.Violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsForbidden(string dottedName)
|
||||||
|
{
|
||||||
|
foreach (var allowed in AllowedExceptions)
|
||||||
|
{
|
||||||
|
if (dottedName == allowed || dottedName.StartsWith(allowed + ".", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var forbidden in ForbiddenNamespaces)
|
||||||
|
{
|
||||||
|
if (dottedName == forbidden || dottedName.StartsWith(forbidden + ".", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ForbiddenApiWalker : CSharpSyntaxWalker
|
||||||
|
{
|
||||||
|
private readonly List<string> _violations = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Violations => _violations;
|
||||||
|
|
||||||
|
public override void VisitUsingDirective(UsingDirectiveSyntax node)
|
||||||
|
{
|
||||||
|
if (node.Name is not null && IsForbidden(node.Name.ToString()))
|
||||||
|
_violations.Add($"forbidden namespace import '{node.Name}'");
|
||||||
|
|
||||||
|
base.VisitUsingDirective(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void VisitQualifiedName(QualifiedNameSyntax node)
|
||||||
|
{
|
||||||
|
// Check the longest qualified name; do not descend so a single
|
||||||
|
// System.IO.File reference is reported once, not three times.
|
||||||
|
var text = node.ToString();
|
||||||
|
if (IsForbidden(text))
|
||||||
|
{
|
||||||
|
_violations.Add($"forbidden type reference '{text}'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.VisitQualifiedName(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
|
||||||
|
{
|
||||||
|
// Catches fully-qualified expressions such as System.IO.File.Delete(...).
|
||||||
|
var text = node.ToString();
|
||||||
|
if (IsForbidden(text))
|
||||||
|
{
|
||||||
|
_violations.Add($"forbidden API access '{text}'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.VisitMemberAccessExpression(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
@@ -14,7 +15,11 @@ namespace ScadaLink.InboundAPI;
|
|||||||
public class InboundScriptExecutor
|
public class InboundScriptExecutor
|
||||||
{
|
{
|
||||||
private readonly ILogger<InboundScriptExecutor> _logger;
|
private readonly ILogger<InboundScriptExecutor> _logger;
|
||||||
private readonly Dictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
|
|
||||||
|
// InboundAPI-001: this executor is registered as a singleton and its handler cache
|
||||||
|
// is read and written from concurrent ASP.NET request threads. A plain Dictionary is
|
||||||
|
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
|
||||||
|
private readonly ConcurrentDictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
|
||||||
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
@@ -37,18 +42,45 @@ public class InboundScriptExecutor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void RemoveHandler(string methodName)
|
public void RemoveHandler(string methodName)
|
||||||
{
|
{
|
||||||
_scriptHandlers.Remove(methodName);
|
_scriptHandlers.TryRemove(methodName, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compiles and registers a single API method script.
|
/// Compiles and registers a single API method script. Returns <c>false</c> if the
|
||||||
|
/// script is empty, fails Roslyn compilation, or violates the script trust model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool CompileAndRegister(ApiMethod method)
|
public bool CompileAndRegister(ApiMethod method)
|
||||||
|
=> Compile(method) is { } handler && Register(method.Name, handler);
|
||||||
|
|
||||||
|
private bool Register(string methodName, Func<InboundScriptContext, Task<object?>> handler)
|
||||||
|
{
|
||||||
|
_scriptHandlers[methodName] = handler;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles a single API method script into an executable handler. Returns
|
||||||
|
/// <c>null</c> when the script is missing, fails to compile, or violates the
|
||||||
|
/// script trust model (InboundAPI-005). Does not mutate the handler cache.
|
||||||
|
/// </summary>
|
||||||
|
private Func<InboundScriptContext, Task<object?>>? Compile(ApiMethod method)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(method.Script))
|
if (string.IsNullOrWhiteSpace(method.Script))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("API method {Method} has no script code", method.Name);
|
_logger.LogWarning("API method {Method} has no script code", method.Name);
|
||||||
return false;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboundAPI-005: enforce the script trust model before compiling. Roslyn
|
||||||
|
// scripting performs no API allow/deny-listing, so forbidden namespaces must
|
||||||
|
// be rejected statically or the script could reach the host process.
|
||||||
|
var violations = ForbiddenApiChecker.FindViolations(method.Script);
|
||||||
|
if (violations.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"API method {Method} script rejected — trust model violation(s): {Violations}",
|
||||||
|
method.Name, string.Join("; ", violations));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -83,22 +115,20 @@ public class InboundScriptExecutor
|
|||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"API method {Method} script compilation failed: {Errors}",
|
"API method {Method} script compilation failed: {Errors}",
|
||||||
method.Name, string.Join("; ", errors));
|
method.Name, string.Join("; ", errors));
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_scriptHandlers[method.Name] = async ctx =>
|
_logger.LogInformation("API method {Method} script compiled", method.Name);
|
||||||
|
return async ctx =>
|
||||||
{
|
{
|
||||||
var state = await compiled.RunAsync(ctx, ctx.CancellationToken);
|
var state = await compiled.RunAsync(ctx, ctx.CancellationToken);
|
||||||
return state.ReturnValue;
|
return state.ReturnValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("API method {Method} script compiled and registered", method.Name);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to compile API method {Method} script", method.Name);
|
_logger.LogError(ex, "Failed to compile API method {Method} script", method.Name);
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,15 +149,18 @@ public class InboundScriptExecutor
|
|||||||
|
|
||||||
var context = new InboundScriptContext(parameters, route, cts.Token);
|
var context = new InboundScriptContext(parameters, route, cts.Token);
|
||||||
|
|
||||||
object? result;
|
|
||||||
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||||
{
|
{
|
||||||
// Lazy compile on first request (handles methods created after startup)
|
// Lazy compile on first request (handles methods created after startup).
|
||||||
if (!CompileAndRegister(method))
|
// Compile outside the cache so a failed compile is not stored, then add
|
||||||
|
// atomically so concurrent first-callers share a single handler instance.
|
||||||
|
var compiled = Compile(method);
|
||||||
|
if (compiled == null)
|
||||||
return new InboundScriptResult(false, null, "Script compilation failed for this method");
|
return new InboundScriptResult(false, null, "Script compilation failed for this method");
|
||||||
handler = _scriptHandlers[method.Name];
|
handler = _scriptHandlers.GetOrAdd(method.Name, compiled);
|
||||||
}
|
}
|
||||||
result = await handler(context).WaitAsync(cts.Token);
|
|
||||||
|
var result = await handler(context).WaitAsync(cts.Token);
|
||||||
|
|
||||||
var resultJson = result != null
|
var resultJson = result != null
|
||||||
? JsonSerializer.Serialize(result)
|
? JsonSerializer.Serialize(result)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class ApiKeyValidatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvalidApiKey_Returns401()
|
public async Task InvalidApiKey_Returns401()
|
||||||
{
|
{
|
||||||
_repository.GetApiKeyByValueAsync("bad-key").Returns((ApiKey?)null);
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey>());
|
||||||
|
|
||||||
var result = await _validator.ValidateAsync("bad-key", "testMethod");
|
var result = await _validator.ValidateAsync("bad-key", "testMethod");
|
||||||
Assert.False(result.IsValid);
|
Assert.False(result.IsValid);
|
||||||
@@ -48,7 +48,7 @@ public class ApiKeyValidatorTests
|
|||||||
public async Task DisabledApiKey_Returns401()
|
public async Task DisabledApiKey_Returns401()
|
||||||
{
|
{
|
||||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
|
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
|
||||||
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||||
|
|
||||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||||
Assert.False(result.IsValid);
|
Assert.False(result.IsValid);
|
||||||
@@ -59,7 +59,7 @@ public class ApiKeyValidatorTests
|
|||||||
public async Task ValidKey_MethodNotFound_Returns400()
|
public async Task ValidKey_MethodNotFound_Returns400()
|
||||||
{
|
{
|
||||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||||
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||||
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
|
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
|
||||||
|
|
||||||
var result = await _validator.ValidateAsync("valid-key", "nonExistent");
|
var result = await _validator.ValidateAsync("valid-key", "nonExistent");
|
||||||
@@ -73,7 +73,7 @@ public class ApiKeyValidatorTests
|
|||||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||||
|
|
||||||
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ public class ApiKeyValidatorTests
|
|||||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||||
|
|
||||||
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||||
|
|
||||||
@@ -98,4 +98,50 @@ public class ApiKeyValidatorTests
|
|||||||
Assert.Equal(key, result.ApiKey);
|
Assert.Equal(key, result.ApiKey);
|
||||||
Assert.Equal(method, result.Method);
|
Assert.Equal(method, result.Method);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- InboundAPI-003: API key must not be matched with a non-constant-time
|
||||||
|
// (timing-oracle) secret-equality lookup. ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAsync_DoesNotUseSecretEqualityLookup()
|
||||||
|
{
|
||||||
|
// GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
|
||||||
|
// comparison — a timing side-channel. The validator must not call it.
|
||||||
|
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||||
|
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||||
|
|
||||||
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||||
|
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||||
|
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||||
|
|
||||||
|
await _validator.ValidateAsync("valid-key", "testMethod");
|
||||||
|
|
||||||
|
await _repository.DidNotReceive()
|
||||||
|
.GetApiKeyByValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401()
|
||||||
|
{
|
||||||
|
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||||
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||||
|
|
||||||
|
var result = await _validator.ValidateAsync("wrong-key", "testMethod");
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Equal(401, result.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAsync_KeyOfDifferentLength_Returns401()
|
||||||
|
{
|
||||||
|
// FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking.
|
||||||
|
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||||
|
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||||
|
|
||||||
|
var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod");
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Equal(401, result.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,4 +132,111 @@ public class InboundScriptExecutorTests
|
|||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.Contains("ScadaLink", result.ResultJson!);
|
Assert.Contains("ScadaLink", result.ResultJson!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- InboundAPI-001: concurrent lazy-compile must not corrupt the handler cache ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache()
|
||||||
|
{
|
||||||
|
// Many concurrent first-callers of an uncompiled method race the lazy-compile
|
||||||
|
// path. With an unsynchronized Dictionary this can throw or return a torn/null
|
||||||
|
// handler; all calls must succeed and produce the same result.
|
||||||
|
var method = new ApiMethod("concurrent", "return 7;") { Id = 1, TimeoutSeconds = 10 };
|
||||||
|
|
||||||
|
var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() =>
|
||||||
|
_executor.ExecuteAsync(
|
||||||
|
method,
|
||||||
|
new Dictionary<string, object?>(),
|
||||||
|
_route,
|
||||||
|
TimeSpan.FromSeconds(10)))).ToArray();
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
Assert.All(results, r =>
|
||||||
|
{
|
||||||
|
Assert.True(r.Success, r.ErrorMessage);
|
||||||
|
Assert.Equal("7", r.ResultJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentRegisterAndExecute_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// RegisterHandler/RemoveHandler racing ExecuteAsync must not crash the process
|
||||||
|
// with an InvalidOperationException from concurrent Dictionary mutation.
|
||||||
|
var method = new ApiMethod("racy", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||||
|
|
||||||
|
var writers = Enumerable.Range(0, 32).Select(i => Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (var n = 0; n < 50; n++)
|
||||||
|
{
|
||||||
|
_executor.RegisterHandler("racy", async ctx => { await Task.CompletedTask; return i; });
|
||||||
|
_executor.RemoveHandler("racy");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
var readers = Enumerable.Range(0, 32).Select(_ => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
for (var n = 0; n < 50; n++)
|
||||||
|
{
|
||||||
|
await _executor.ExecuteAsync(
|
||||||
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Should complete without an unhandled concurrency exception.
|
||||||
|
await Task.WhenAll(writers.Concat(readers));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- InboundAPI-005: compiled scripts must not bypass the script trust model ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
|
||||||
|
[InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")]
|
||||||
|
[InlineData("var t = System.Reflection.Assembly.GetExecutingAssembly(); return null;")]
|
||||||
|
[InlineData("new System.Threading.Thread(() => {}).Start(); return null;")]
|
||||||
|
[InlineData("var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); return null;")]
|
||||||
|
public void CompileAndRegister_ForbiddenApi_RejectsScript(string script)
|
||||||
|
{
|
||||||
|
var method = new ApiMethod("forbidden", script) { Id = 1, TimeoutSeconds = 10 };
|
||||||
|
|
||||||
|
var registered = _executor.CompileAndRegister(method);
|
||||||
|
|
||||||
|
Assert.False(registered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure()
|
||||||
|
{
|
||||||
|
// A fully-qualified forbidden API call must be rejected at compile/register time
|
||||||
|
// so the script never executes.
|
||||||
|
var marker = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"scadalink-pwned-{Guid.NewGuid():N}");
|
||||||
|
System.IO.File.Delete(marker);
|
||||||
|
|
||||||
|
var method = new ApiMethod("evil", $"System.IO.File.WriteAllText(@\"{marker}\", \"x\"); return 1;")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TimeoutSeconds = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _executor.ExecuteAsync(
|
||||||
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.False(System.IO.File.Exists(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompileAndRegister_PermittedScript_StillRegisters()
|
||||||
|
{
|
||||||
|
// The trust-model check must not reject legitimate scripts.
|
||||||
|
var method = new ApiMethod("ok", "var list = new List<int> { 1, 2, 3 }; return list.Sum();")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TimeoutSeconds = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(_executor.CompileAndRegister(method));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user