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
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
@@ -14,7 +15,11 @@ namespace ScadaLink.InboundAPI;
public class InboundScriptExecutor
{
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;
@@ -37,18 +42,45 @@ public class InboundScriptExecutor
/// </summary>
public void RemoveHandler(string methodName)
{
_scriptHandlers.Remove(methodName);
_scriptHandlers.TryRemove(methodName, out _);
}
/// <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>
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))
{
_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
@@ -83,22 +115,20 @@ public class InboundScriptExecutor
_logger.LogWarning(
"API method {Method} script compilation failed: {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);
return state.ReturnValue;
};
_logger.LogInformation("API method {Method} script compiled and registered", method.Name);
return true;
}
catch (Exception ex)
{
_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);
object? result;
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
{
// Lazy compile on first request (handles methods created after startup)
if (!CompileAndRegister(method))
// Lazy compile on first request (handles methods created after startup).
// 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");
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
? JsonSerializer.Serialize(result)