fix(scripting): deny System.AppContext and other process-wide mutators in the script sandbox (lmxopcua#792)
v2-ci / unit-tests (push) Waiting to run
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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user