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

@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
@@ -30,7 +32,14 @@ public class ApiKeyValidator
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)
{
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
@@ -53,6 +62,31 @@ public class ApiKeyValidator
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>

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