fix(scripting): deny System.AppContext and other process-wide mutators in the script sandbox (lmxopcua#792)
v2-ci / unit-tests (push) Waiting to run

ForbiddenTypeAnalyzer now denies System.AppContext, System.Console, GCSettings, ProfileOptimization,
EncodingProvider/CodePagesEncodingProvider, TypeDescriptor and CryptoConfig as types, and the ambient
CultureInfo.{DefaultThreadCurrentCulture,DefaultThreadCurrentUICulture,CurrentCulture,CurrentUICulture}
and TimeZoneInfo.ClearCachedData members (member deny-list now covers properties and member-access reads).
Shadow-mode tests diverge via culture-sensitive string.Compare (worker is InvariantGlobalization) instead of
AppContext.BaseDirectory, with a premise test. Mutation-proved: 21 new cases fail with the analyzer reverted.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 07:48:39 -05:00
parent b1eedceacf
commit 0e96dd08a2
5 changed files with 161 additions and 27 deletions
@@ -129,6 +129,18 @@ public static class ForbiddenTypeAnalyzer
("System.Type", "GetEvent"),
("System.Type", "GetEvents"),
("System.Type", "GetDefaultMembers"),
// Process-wide mutators on otherwise-useful types (lmxopcua#792). CultureInfo itself must stay
// reachable (CultureInfo.InvariantCulture is the right way to format), but its ambient-culture
// properties are process / pool-thread state: Default* apply to every thread created afterwards, and
// CurrentCulture / CurrentUICulture stick to the pooled thread the script ran on. The whole property is
// denied (read and write) — a script that wants a culture names InvariantCulture or CultureInfo.GetCultureInfo.
("System.Globalization.CultureInfo", "DefaultThreadCurrentCulture"),
("System.Globalization.CultureInfo", "DefaultThreadCurrentUICulture"),
("System.Globalization.CultureInfo", "CurrentCulture"),
("System.Globalization.CultureInfo", "CurrentUICulture"),
// Drops the process-wide time-zone cache.
("System.TimeZoneInfo", "ClearCachedData"),
];
/// <summary>
@@ -188,6 +200,30 @@ public static class ForbiddenTypeAnalyzer
// included — without a constructor or a syntactic type name. Same namespace caveat
// as Unsafe above, so type-granular.
"System.Runtime.CompilerServices.RuntimeHelpers",
// lmxopcua#792 — process-wide runtime configuration. System.AppContext.SetSwitch / SetData flip
// BCL compatibility switches (serialization, networking, globalization) for the whole process and
// GetData / BaseDirectory leak the host's install path and TRUSTED_PLATFORM_ASSEMBLIES. The whole
// type is denied (same class as Environment / AppDomain, which are already denied); TryGetSwitch is
// read-only but has no script use either.
"System.AppContext",
// Console.SetOut / SetError / SetIn / Out / Write* replace or write the process's standard streams —
// in the ScriptWorker stdout/stderr belong to the launcher, in the host they belong to the service
// log capture. Scripts log through ctx.Logger.
"System.Console",
// GCSettings.LatencyMode / LargeObjectHeapCompactionMode are process-wide GC tuning knobs.
"System.Runtime.GCSettings",
// ProfileOptimization.SetProfileRoot / StartProfile redirect the JIT profile root (a file-writing,
// process-wide setting).
"System.Runtime.ProfileOptimization",
// EncodingProvider registration is process-wide (Encoding.RegisterProvider takes an EncodingProvider).
"System.Text.EncodingProvider",
"System.Text.CodePagesEncodingProvider",
// TypeDescriptor.AddProvider / AddAttributes change type metadata for the whole process, and
// CreateInstance builds a type by name.
"System.ComponentModel.TypeDescriptor",
// CryptoConfig.AddAlgorithm / AddOID mutate the process-wide algorithm map, and CreateFromName builds
// a type by name (an Activator-equivalent).
"System.Security.Cryptography.CryptoConfig",
];
/// <summary>
@@ -252,6 +288,11 @@ public static class ForbiddenTypeAnalyzer
// MemberAccess "System.IO.File" resolves to the File type symbol
// whose containing namespace is System.IO, triggering a rejection.
CheckSymbol(semantic.GetSymbolInfo(mem.Expression).Symbol, mem.Expression.Span, rejections);
// The accessed member itself: a property such as CultureInfo.DefaultThreadCurrentCulture is
// never an invocation, so the member deny-list must run here (member-only — resolving the
// type of an inherited member would flag harmless reads, see the class remarks).
var accessed = semantic.GetSymbolInfo(mem).Symbol;
TryRejectMember(accessed, mem.Span, accessed?.ContainingNamespace?.ToDisplayString() ?? string.Empty, rejections);
break;
case IdentifierNameSyntax id when node.Parent is not MemberAccessExpressionSyntax:
CheckSymbol(semantic.GetSymbolInfo(id).Symbol, id.Span, rejections);
@@ -294,6 +335,34 @@ public static class ForbiddenTypeAnalyzer
}
}
/// <summary>
/// Member deny-list check on its own (no type / namespace rule): rejects a method or property whose declaring
/// type and name are listed in <see cref="ForbiddenMembers"/>. Exact declaring-type match, so an inherited
/// harmless member is never flagged.
/// </summary>
private static bool TryRejectMember(ISymbol? symbol, TextSpan span, string ns, List<ForbiddenTypeRejection> rejections)
{
if (symbol is not (IMethodSymbol or IPropertySymbol)) return false;
var declaring = symbol.ContainingType.OriginalDefinition.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(
SymbolDisplayGlobalNamespaceStyle.Omitted));
foreach (var (type, member) in ForbiddenMembers)
{
if (declaring != type || symbol.Name != member) continue;
if (rejections.Any(r => r.Span == span && r.TypeName == $"{type}.{member}")) return true;
rejections.Add(new ForbiddenTypeRejection(
Span: span,
TypeName: $"{type}.{member}",
Namespace: ns,
Message: $"Member '{type}.{member}' is on the sandbox forbidden-member deny-list: " +
"it obtains a type or member by name or changes process-wide state, which bypasses the sandbox " +
"(see ForbiddenTypeAnalyzer.ForbiddenMembers for the authoritative list)."));
return true;
}
return false;
}
private static void CheckSymbol(ISymbol? symbol, TextSpan span, List<ForbiddenTypeRejection> rejections)
{
if (symbol is null) return;
@@ -327,26 +396,8 @@ public static class ForbiddenTypeAnalyzer
// Member deny-list first: the containing type (System.Type) is allowed, so neither list
// below would ever look at the member name.
if (symbol is IMethodSymbol method)
{
var declaring = method.ContainingType.OriginalDefinition.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(
SymbolDisplayGlobalNamespaceStyle.Omitted));
foreach (var (type, member) in ForbiddenMembers)
{
if (declaring == type && method.Name == member)
{
rejections.Add(new ForbiddenTypeRejection(
Span: span,
TypeName: $"{type}.{member}",
Namespace: ns,
Message: $"Member '{type}.{member}' is on the sandbox forbidden-member deny-list: " +
"it obtains a type or member by name, which bypasses the type allow-list " +
"(see ForbiddenTypeAnalyzer.ForbiddenMembers for the authoritative list)."));
return;
}
}
}
if (TryRejectMember(symbol, span, ns, rejections))
return;
// A type on the allow-list stays reachable even though its namespace is denied; it is still
// subject to the type-granular deny-list below (nothing is on both today).