feat(scripting): deny System.Threading, Type reflection entry points and RuntimeHelpers in the sandbox (E2)

R05:10 — the namespace deny-list covered System.Threading.Tasks but not
System.Threading, so SemaphoreSlim / Monitor / SpinWait / Mutex /
EventWaitHandle could park a script's pool thread past its budget (or touch a
system-global kernel object); System.Type.GetType(string) / GetMethod* /
InvokeMember and System.Runtime.CompilerServices.RuntimeHelpers resolved and
reach a forbidden type without naming it syntactically.

ForbiddenTypeAnalyzer now denies the whole System.Threading namespace with a
type-granular allow-list (CancellationToken, Interlocked, Volatile — the
sync-free primitives), adds a member deny-list for System.Type's reflection
entry points (GetType/GetMethod(s)/InvokeMember/GetProperty(ies)/GetField(s)/
GetMember(s)/GetConstructor(s)/GetEvent(s)/GetDefaultMembers; instance
object.GetType() stays allowed) and puts RuntimeHelpers on the type deny-list.
Thread/ThreadPool/Timer leave the type list — the prefix rule reaches them.

R05:11 — the allocation budget landed with E1's ScriptRunGuard
(Scripting:MaxAllocBytesPerRun, default 64 MB): sampled around each run,
breach quarantines. It is observation after the fact; a pre-emptive OOM guard
still needs an out-of-process runner (follow-up issue in the lane report).

The Monaco editor shares the analyzer, so DiagnoseTests pins every new form
as an OTSCRIPT_FORBIDDEN marker and every allow-listed form as clean.

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 06:45:03 -04:00
parent eb9269c337
commit 2e9160317f
3 changed files with 300 additions and 59 deletions
@@ -0,0 +1,148 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Sandbox hardening (review R05:10): <c>System.Threading</c> is denied as a namespace with a
/// type-granular allow-list (<c>CancellationToken</c>, <c>Interlocked</c>, <c>Volatile</c>) so a
/// script can no longer park its pool thread on a blocking primitive or touch a system-global kernel
/// object; <c>System.Type</c>'s reflection entry points (<c>GetType(string)</c>, <c>GetMethod*</c>,
/// <c>InvokeMember</c>, …) and <c>System.Runtime.CompilerServices.RuntimeHelpers</c> are denied so a
/// forbidden type cannot be reached by name without ever being named syntactically.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptSandboxHardeningTests
{
private static ScriptSandboxViolationException Rejects(string source) =>
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(source));
// ── System.Threading blocking / synchronisation primitives ───────────────────────────────
[Fact]
public void Rejects_SemaphoreSlim_Wait()
{
var ex = Rejects("new System.Threading.SemaphoreSlim(0).Wait(); return 0;");
ex.Rejections.ShouldContain(r => r.TypeName.Contains("SemaphoreSlim") && r.Namespace == "System.Threading");
}
[Fact]
public void Rejects_SpinWait_SpinUntil()
=> Rejects("System.Threading.SpinWait.SpinUntil(() => false); return 0;");
[Fact]
public void Rejects_Monitor()
=> Rejects("var o = new object(); System.Threading.Monitor.Enter(o); return 0;");
[Fact]
public void Rejects_named_Mutex_system_global_kernel_object()
=> Rejects("using var m = new System.Threading.Mutex(false, \"Global\\\\otopcua\"); return 0;");
[Fact]
public void Rejects_ManualResetEventSlim()
=> Rejects("var e = new System.Threading.ManualResetEventSlim(false); e.Wait(); return 0;");
[Fact]
public void Rejects_EventWaitHandle()
=> Rejects("var e = new System.Threading.EventWaitHandle(false, System.Threading.EventResetMode.ManualReset); return 0;");
[Fact]
public void Rejects_Thread_ThreadPool_and_Timer_still()
{
Rejects("var t = new System.Threading.Thread(() => { }); return 0;");
Rejects("System.Threading.ThreadPool.QueueUserWorkItem(_ => { }); return 0;");
Rejects("var t = new System.Threading.Timer(_ => { }); return 0;");
}
[Fact]
public void Rejects_Thread_Sleep()
=> Rejects("System.Threading.Thread.Sleep(10); return 0;");
// ── the allow-list inside System.Threading ───────────────────────────────────────────────
[Fact]
public async Task Allows_CancellationToken()
{
using var ev = ScriptEvaluator<FakeScriptContext, int>.Compile(
"var t = System.Threading.CancellationToken.None; return t.IsCancellationRequested ? 1 : 0;");
(await ev.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken)).ShouldBe(0);
}
[Fact]
public async Task Allows_Interlocked()
{
using var ev = ScriptEvaluator<FakeScriptContext, int>.Compile(
"var x = 41; System.Threading.Interlocked.Increment(ref x); return x;");
(await ev.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken)).ShouldBe(42);
}
[Fact]
public async Task Allows_Volatile()
{
using var ev = ScriptEvaluator<FakeScriptContext, int>.Compile(
"var x = 42; return System.Threading.Volatile.Read(ref x);");
(await ev.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken)).ShouldBe(42);
}
// ── System.Type reflection entry points ──────────────────────────────────────────────────
[Fact]
public void Rejects_Type_GetType_by_name()
{
var ex = Rejects("var t = System.Type.GetType(\"System.IO.File\"); return t is null ? 0 : 1;");
ex.Rejections.ShouldContain(r => r.Message.Contains("GetType"));
}
[Fact]
public void Rejects_Type_GetMethod()
=> Rejects("var m = typeof(string).GetMethod(\"Trim\"); return m is null ? 0 : 1;");
[Fact]
public void Rejects_Type_GetMethods()
=> Rejects("return typeof(string).GetMethods().Length;");
[Fact]
public void Rejects_Type_InvokeMember()
=> Rejects("""return (int)typeof(string).InvokeMember("Length", System.Reflection.BindingFlags.GetProperty, null, "abc", null)!;""");
[Fact]
public void Rejects_Type_GetProperty_GetField_GetMember_GetConstructor_GetEvent()
{
Rejects("return typeof(string).GetProperty(\"Length\") is null ? 0 : 1;");
Rejects("return typeof(string).GetField(\"Empty\") is null ? 0 : 1;");
Rejects("return typeof(string).GetMember(\"Length\").Length;");
Rejects("return typeof(string).GetConstructors().Length;");
Rejects("return typeof(string).GetEvent(\"x\") is null ? 0 : 1;");
}
[Fact]
public async Task Allows_instance_GetType_and_Type_metadata_reads()
{
// object.GetType() is declared on System.Object, and Type.Name / IsValueType are plain metadata
// reads — neither is a reflection entry point, and both are common in authored scripts.
using var ev = ScriptEvaluator<FakeScriptContext, int>.Compile(
"object o = 42; var t = o.GetType(); return t.IsValueType && typeof(int).Name == \"Int32\" ? 1 : 0;");
(await ev.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken)).ShouldBe(1);
}
// ── RuntimeHelpers ───────────────────────────────────────────────────────────────────────
[Fact]
public void Rejects_RuntimeHelpers()
{
var ex = Rejects(
"var o = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(string)); return o is null ? 0 : 1;");
ex.Rejections.ShouldContain(r => r.TypeName.Contains("RuntimeHelpers"));
}
[Fact]
public async Task Allows_compile_time_only_CompilerServices_attributes()
{
// The namespace stays open for the harmless compile-time types (MethodImplOptions etc.).
using var ev = ScriptEvaluator<FakeScriptContext, int>.Compile(
"var o = System.Runtime.CompilerServices.MethodImplOptions.NoInlining; return (int)o;");
(await ev.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken)).ShouldBe(8);
}
}
@@ -31,6 +31,26 @@ public sealed class DiagnoseTests
=> Diag("""var c = new System.Net.Http.HttpClient(); return 0;""")
.ShouldContain(x => x.Severity == 8);
// Sandbox hardening parity (R05:10): the editor runs the SAME ForbiddenTypeAnalyzer the publish gate
// runs, so every newly-denied form must surface as an OTSCRIPT_FORBIDDEN marker here — editor accepts
// ⇔ publish accepts. One case per deny mechanism (namespace, member, type), plus the allow-list.
[Theory]
[InlineData("""new System.Threading.SemaphoreSlim(0).Wait(); return 0;""")] // System.Threading namespace
[InlineData("""System.Threading.SpinWait.SpinUntil(() => false); return 0;""")]
[InlineData("""var t = System.Type.GetType("System.IO.File"); return t is null ? 0 : 1;""")] // forbidden member
[InlineData("""return typeof(string).GetMethods().Length;""")]
[InlineData("""var o = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(string)); return 0;""")] // forbidden type
public void Hardened_sandbox_forms_are_flagged_in_the_editor(string code)
=> Diag(code).ShouldContain(x => x.Code == "OTSCRIPT_FORBIDDEN" && x.Severity == 8);
[Theory]
[InlineData("""var t = System.Threading.CancellationToken.None; return t.IsCancellationRequested ? 1 : 0;""")]
[InlineData("""var x = 41; System.Threading.Interlocked.Increment(ref x); return x;""")]
[InlineData("""var x = 42; return System.Threading.Volatile.Read(ref x);""")]
[InlineData("""object o = 42; return o.GetType().IsValueType ? 1 : 0;""")]
public void Allow_listed_forms_are_not_flagged_in_the_editor(string code)
=> Diag(code).ShouldBeEmpty();
[Fact] public void Dynamic_tag_path_is_flagged()
=> Diag("""var p = "A"; return ctx.GetTag(p).Value;""")
.ShouldContain(x => x.Message.Contains("literal"));