Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs
Joseph Doherty cfb9ff1032 fix(scripting): block dangerous System types in the script sandbox (Core.Scripting-001)
ForbiddenTypeAnalyzer used only a namespace-prefix deny-list. System.Environment,
System.AppDomain, System.GC and System.Activator live directly in the System
namespace, which must stay allowed for primitives (Math, String, ...), so they
were never caught — an operator-authored predicate could call
System.Environment.Exit(0) and terminate the in-process OPC UA server.

Add a type-granular deny-list (ForbiddenFullTypeNames) checked by
fully-qualified type name after the namespace-prefix check; legitimate System
types are unaffected.

Regression tests assert scripts referencing Environment/AppDomain/GC/Activator
are rejected at analysis time. Core.Scripting suite: 68/68 pass.

Resolves code-review finding Core.Scripting-001 (Critical).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 05:54:08 -04:00

243 lines
8.7 KiB
C#

using Microsoft.CodeAnalysis.Scripting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
/// Locks decision #6 — scripts can't escape to the broader .NET surface.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptSandboxTests
{
[Fact]
public void Happy_path_script_compiles_and_returns()
{
// Baseline — ctx + Math + basic types must work.
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
"""
var v = (double)ctx.GetTag("X").Value;
return Math.Abs(v) * 2.0;
""");
evaluator.ShouldNotBeNull();
}
[Fact]
public async Task Happy_path_script_runs_and_reads_seeded_tag()
{
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
"""return (double)ctx.GetTag("In").Value * 2.0;""");
var ctx = new FakeScriptContext().Seed("In", 21.0);
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
result.ShouldBe(42.0);
}
[Fact]
public async Task SetVirtualTag_records_the_write()
{
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
ctx.SetVirtualTag("Out", 42);
return 0;
""");
var ctx = new FakeScriptContext();
await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
ctx.Writes.Count.ShouldBe(1);
ctx.Writes[0].Path.ShouldBe("Out");
ctx.Writes[0].Value.ShouldBe(42);
}
[Fact]
public void Rejects_File_IO_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, string>.Compile(
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
}
[Fact]
public void Rejects_HttpClient_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var c = new System.Net.Http.HttpClient();
return 0;
"""));
}
[Fact]
public void Rejects_Process_Start_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Diagnostics.Process.Start("cmd.exe");
return 0;
"""));
}
[Fact]
public void Rejects_Reflection_Assembly_Load_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Reflection.Assembly.Load("System.Core");
return 0;
"""));
}
[Fact]
public void Rejects_Environment_Exit_at_compile()
{
// System.Environment lives in System.Private.CoreLib (allow-listed for
// primitives) so a namespace-prefix deny-list cannot block it. Environment.Exit
// terminates the whole in-process OPC UA server — every connected client and
// every driver — so it MUST be rejected member-granularly. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Environment.Exit(0);
return 0;
"""));
}
[Fact]
public void Rejects_Environment_FailFast_at_compile()
{
// Environment.FailFast crashes the host process immediately — same outage as
// Exit. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Environment.FailFast("boom");
return 0;
"""));
}
[Fact]
public void Rejects_AppDomain_at_compile()
{
// AppDomain.CurrentDomain exposes process-wide control (assembly load events,
// unhandled-exception hooks). Not script surface. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var n = System.AppDomain.CurrentDomain.FriendlyName;
return 0;
"""));
}
[Fact]
public void Rejects_GC_Collect_at_compile()
{
// GC.Collect / GC.AddMemoryPressure let a script perturb the whole process's
// memory subsystem. Not script surface. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.GC.Collect();
return 0;
"""));
}
[Fact]
public void Rejects_Activator_CreateInstance_at_compile()
{
// Activator.CreateInstance is a reflection-equivalent escape — it can construct
// a forbidden type by name without ever naming it syntactically. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var o = System.Activator.CreateInstance(typeof(object));
return 0;
"""));
}
[Fact]
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
{
// The whole System.Environment type is forbidden (Core.Scripting-001) — even the
// read-only GetEnvironmentVariable member. Once Exit / FailFast made the type
// dangerous, the cleanest member-granular rule is to deny the type outright; the
// read path has no legitimate use in a SCADA predicate either.
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, string?>.Compile(
"""return System.Environment.GetEnvironmentVariable("PATH");"""));
}
[Fact]
public async Task Script_exception_propagates_unwrapped()
{
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""throw new InvalidOperationException("boom");""");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
}
[Fact]
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
{
// Scripts that need a timestamp go through ctx.Now so tests can pin it.
var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
evaluator.ShouldNotBeNull();
}
[Fact]
public void Deadband_helper_is_reachable_from_scripts()
{
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
"""return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
evaluator.ShouldNotBeNull();
}
[Fact]
public async Task Linq_Enumerable_is_available_from_scripts()
{
// LINQ is in the allow-list because SCADA math frequently wants Sum / Average
// / Where. Confirm it works.
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var nums = new[] { 1, 2, 3, 4, 5 };
return nums.Where(n => n > 2).Sum();
""");
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
result.ShouldBe(12);
}
[Fact]
public async Task DataValueSnapshot_is_usable_in_scripts()
{
// ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
"""
var v = ctx.GetTag("T");
return v.StatusCode == 0;
""");
var ctx = new FakeScriptContext().Seed("T", 5.0);
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
result.ShouldBeTrue();
}
[Fact]
public void Compile_error_gives_location_in_diagnostics()
{
// Compile errors must carry the source span so the Admin UI can point at them.
try
{
ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
Assert.Fail("expected CompilationErrorException");
}
catch (CompilationErrorException ex)
{
ex.Diagnostics.ShouldNotBeEmpty();
ex.Diagnostics[0].Location.ShouldNotBeNull();
}
}
}