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