fix(scripting): resolve High code-review finding (Core.Scripting-002)

The ForbiddenTypeAnalyzer syntax walker only inspected four node kinds
(ObjectCreation, Invocation-with-member-access, MemberAccess, bare
Identifier), so a forbidden type named through typeof, a generic type
argument, a cast, an is/as type pattern, default(T), an array-creation
element type, or an explicitly-typed local declaration produced no
examined node and bypassed the sandbox check.

Analyze now runs a second pass that resolves GetTypeInfo on every
TypeSyntax node and recursively unwraps array element types and generic
type arguments, so forbidden types nested at any depth are rejected at
compile. The original member/call node-kind switch is kept deliberately
narrow (rather than resolving GetSymbolInfo on every node) to avoid
flagging harmless inherited members such as typeof(int).Name, whose Name
property is declared by System.Reflection.MemberInfo. A span+type dedupe
keeps the two passes from emitting duplicate rejections.

Regression tests added in ScriptSandboxTests cover typeof, generic type
arguments, casts, default(T), is/as patterns, array element types, and
typed local declarations with forbidden types, plus over-block guards
asserting allowed generics and typeof still compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:08:08 -04:00
parent 8c7c605478
commit 7bb21c2aa2
3 changed files with 230 additions and 5 deletions

View File

@@ -171,6 +171,152 @@ public sealed class ScriptSandboxTests
"""return System.Environment.GetEnvironmentVariable("PATH");"""));
}
// --- Core.Scripting-002: type-reference node forms that bypassed the old walker ---
// The old walker only inspected ObjectCreation / Invocation-with-member-access /
// MemberAccess / bare-Identifier nodes. typeof, generic type arguments, casts,
// is/as patterns, default(T), array element types, and declared variable types all
// name a forbidden type without producing any of those nodes. The broadened walker
// resolves GetTypeInfo on every TypeSyntax / ExpressionSyntax so they are all caught.
[Fact]
public void Rejects_typeof_forbidden_type_at_compile()
{
// Phase 7 plan A.6 explicitly calls out typeof as a sandbox-escape that must
// fail at compile. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, string>.Compile(
"""return typeof(System.IO.File).Name;"""));
}
[Fact]
public void Rejects_generic_type_argument_forbidden_type_at_compile()
{
// new List<System.IO.FileInfo>() — the forbidden type is nested inside an
// allowed generic. The walker unwraps type arguments recursively. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var l = new System.Collections.Generic.List<System.IO.FileInfo>();
return l.Count;
"""));
}
[Fact]
public void Rejects_cast_to_forbidden_type_at_compile()
{
// (System.IO.Stream)null — a cast expression names the type without invoking
// or constructing it. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var s = (System.IO.Stream)null;
return 0;
"""));
}
[Fact]
public void Rejects_default_of_forbidden_type_at_compile()
{
// default(System.Reflection.Assembly) names a forbidden type via the default
// operator. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var a = default(System.Reflection.Assembly);
return 0;
"""));
}
[Fact]
public void Rejects_is_pattern_forbidden_type_at_compile()
{
// 'o is System.IO.FileStream' names a forbidden type as the pattern's type
// operand. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, bool>.Compile(
"""
object o = ctx.GetTag("X").Value;
return o is System.IO.FileStream;
"""));
}
[Fact]
public void Rejects_as_expression_forbidden_type_at_compile()
{
// 'o as System.IO.Stream' names a forbidden type via the as operator. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
object o = ctx.GetTag("X").Value;
var s = o as System.IO.Stream;
return 0;
"""));
}
[Fact]
public void Rejects_array_creation_forbidden_element_type_at_compile()
{
// new System.IO.FileInfo[0] — the forbidden type is the array element type.
// (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var a = new System.IO.FileInfo[0];
return a.Length;
"""));
}
[Fact]
public void Rejects_local_declared_variable_forbidden_type_at_compile()
{
// An explicitly-typed local declaration 'System.Net.Http.HttpClient c;' names
// a forbidden type with no construction or call. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Net.Http.HttpClient c = null;
return 0;
"""));
}
[Fact]
public void Rejects_typeof_forbidden_type_inside_Activator_at_compile()
{
// Activator is already denied by ForbiddenFullTypeNames, but the typeof operand
// is independently a forbidden-type reference — confirm the typeof path catches
// it even when the Activator type itself were allowed. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var t = typeof(System.Diagnostics.Process);
return 0;
"""));
}
[Fact]
public async Task Allowed_generic_type_argument_still_compiles()
{
// Guard against over-blocking: a generic with only allow-listed type arguments
// (List<int>) must still compile and run. (Core.Scripting-002.)
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var l = new System.Collections.Generic.List<int> { 1, 2, 3 };
return l.Count;
""");
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
result.ShouldBe(3);
}
[Fact]
public async Task Allowed_typeof_still_compiles()
{
// typeof against an allow-listed type (int) must not be rejected. (Core.Scripting-002.)
var evaluator = ScriptEvaluator<FakeScriptContext, string>.Compile(
"""return typeof(int).Name;""");
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
result.ShouldBe("Int32");
}
[Fact]
public async Task Script_exception_propagates_unwrapped()
{