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:
Joseph Doherty
2026-05-16 19:47:17 -04:00
parent d30ded7e72
commit 6f4efdfa2e
6 changed files with 393 additions and 28 deletions

View File

@@ -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

View File

@@ -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>

View 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);
}
}
}

View File

@@ -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)

View File

@@ -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);
}
} }

View File

@@ -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));
}
} }