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