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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user