diff --git a/src/embed_tests/TestClrTypeFilter.cs b/src/embed_tests/TestClrTypeFilter.cs new file mode 100644 index 000000000..447df8754 --- /dev/null +++ b/src/embed_tests/TestClrTypeFilter.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using Python.Runtime; + +namespace Python.EmbeddingTest +{ + public class TestClrTypeFilter + { + static readonly string RefusedName = typeof(FilterRefusedType).FullName!; + + readonly RefuseOneType _filter = new(typeof(FilterRefusedType)); + + // The engine is already running, so the filter is added to the live + // configuration, as Inheritance does with PythonBaseTypeProviders. That + // is safe here because nothing else ever reflects FilterRefusedType, so + // it cannot already be cached + [OneTimeSetUp] + public void SetUp() => PythonEngine.InteropConfiguration.ClrTypeFilters.Add(_filter); + + [OneTimeTearDown] + public void TearDown() => PythonEngine.InteropConfiguration.ClrTypeFilters.Remove(_filter); + + [TestCase("host.Method()", TestName = "MethodReturn")] + [TestCase("host.OutParam(None)", TestName = "OutParameterAndReturn")] + [TestCase("host.OutOnly(None)", TestName = "OutParameterOnly")] + [TestCase("list(host.Iterate())", TestName = "IteratorItem")] + [TestCase("host.Property", TestName = "InstanceProperty")] + [TestCase("type(host).StaticProperty", TestName = "StaticProperty")] + [TestCase("host.Field", TestName = "InstanceField")] + [TestCase("type(host).StaticField", TestName = "StaticField")] + [TestCase("host.Array1[0]", TestName = "ArrayItem")] + [TestCase("host.Array2[0, 0]", TestName = "MultidimensionalArrayItem")] + [TestCase("host[0]", TestName = "IndexerResult")] + [TestCase("host.AsInterface().__implementation__", TestName = "InterfaceImplementation")] + [TestCase("host.AsInterface().__raw_implementation__", TestName = "InterfaceRawImplementation")] + public void RefusedTypeRaisesCatchableTypeError(string expression) + { + using var scope = Py.CreateScope(); + scope.Set("host", new FilterHost()); + scope.Exec($@" +def attempt(): + try: + {expression} + except TypeError as e: + return str(e) + return 'nothing raised' +"); + Assert.That(scope.Eval("attempt()"), Does.Contain(RefusedName)); + } + + [Test] + public void PermittedTypesAreUnaffected() + { + using var scope = Py.CreateScope(); + scope.Set("host", new FilterHost()); + Assert.That(scope.Eval("host.Permitted().Value"), Is.EqualTo(42)); + } + + [Test] + public void UncaughtRefusalReachesTheCallerAsTypeError() + { + using var scope = Py.CreateScope(); + scope.Set("host", new FilterHost()); + + var error = Assert.Throws(() => scope.Exec("host.Method()")); + Assert.That(error.Type.Name, Is.EqualTo("TypeError")); + Assert.That(error.Message, Does.Contain(RefusedName)); + } + + [Test] + public void RefusedTypeIsCheckedOnEveryAttempt() + { + var counter = new CountingFilter(typeof(FilterRefusedType)); + var filters = PythonEngine.InteropConfiguration.ClrTypeFilters; + filters.Insert(0, counter); + try + { + using var scope = Py.CreateScope(); + scope.Set("host", new FilterHost()); + for (var i = 0; i < 2; i++) + Assert.Throws(() => scope.Exec("host.Method()")); + + // A refusal is never cached, so the filters see the type again + Assert.That(counter.Count, Is.EqualTo(2)); + } + finally + { + filters.Remove(counter); + } + } + + [Test] + public void NoFiltersByDefault() + { + using var configuration = InteropConfiguration.MakeDefault(); + Assert.That(configuration.ClrTypeFilters, Is.Empty); + } + + [Test] + public void TypeIsReflectedOnlyIfEveryFilterPermitsIt() + { + var group = new ClrTypeFilterGroup(); + Assert.That(group.ShouldReflect(typeof(object)), Is.True, "an empty group permits everything"); + + group.Add(new RefuseOneType(typeof(string))); + Assert.That(group.ShouldReflect(typeof(object)), Is.True); + Assert.That(group.ShouldReflect(typeof(string)), Is.False); + + group.Add(new RefuseOneType(typeof(object))); + Assert.That(group.ShouldReflect(typeof(object)), Is.False); + } + + sealed class RefuseOneType(Type refused) : IClrTypeFilter + { + public bool ShouldReflect(Type type) => type != refused; + } + + sealed class CountingFilter(Type counted) : IClrTypeFilter + { + public int Count { get; private set; } + + public bool ShouldReflect(Type type) + { + if (type == counted) + Count++; + return true; + } + } + } + + /// Refused by ; reflected nowhere else. + public class FilterRefusedType : IFilterTarget { } + + public interface IFilterTarget { } + + public class FilterPermittedType + { + public int Value => 42; + } + + /// Hands a refused type to Python through each boundary. + public class FilterHost + { + public FilterRefusedType Method() => new(); + public int OutParam(out FilterRefusedType value) { value = new(); return 1; } + public void OutOnly(out FilterRefusedType value) => value = new(); + public IEnumerable Iterate() { yield return new(); } + public FilterRefusedType Property => new(); + public static FilterRefusedType StaticProperty => new(); + public FilterRefusedType Field = new(); + public static FilterRefusedType StaticField = new(); + public FilterRefusedType[] Array1 = { new() }; + public FilterRefusedType[,] Array2 = { { new() } }; + public FilterRefusedType this[int index] => new(); + public IFilterTarget AsInterface() => new FilterRefusedType(); + public FilterPermittedType Permitted() => new(); + } +} diff --git a/src/runtime/ClrTypeFilterGroup.cs b/src/runtime/ClrTypeFilterGroup.cs new file mode 100644 index 000000000..8ee5f6945 --- /dev/null +++ b/src/runtime/ClrTypeFilterGroup.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Python.Runtime +{ + /// + /// Composes the registered filters: a type is reflected only if every filter + /// permits it. An empty group permits everything, which is the default. + /// + class ClrTypeFilterGroup : List, IClrTypeFilter + { + public bool ShouldReflect(Type type) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + + foreach (var filter in this) + if (!filter.ShouldReflect(type)) + return false; + + return true; + } + } +} diff --git a/src/runtime/ClrTypeFilteredException.cs b/src/runtime/ClrTypeFilteredException.cs new file mode 100644 index 000000000..e2632420a --- /dev/null +++ b/src/runtime/ClrTypeFilteredException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Python.Runtime +{ + /// + /// Raised when a CLR type would be reflected into Python but an + /// registered on + /// refused it. + /// + public class ClrTypeFilteredException : Exception + { + public Type FilteredType { get; } + + public ClrTypeFilteredException(Type type) + : base($"Reflecting {type?.FullName ?? ""} into Python was refused by an IClrTypeFilter.") + => FilteredType = type!; + } +} diff --git a/src/runtime/Exceptions.cs b/src/runtime/Exceptions.cs index 85e56eace..d02507d24 100644 --- a/src/runtime/Exceptions.cs +++ b/src/runtime/Exceptions.cs @@ -202,6 +202,14 @@ public static bool SetError(Exception e) return true; } + // Raised as a plain TypeError: wrapping the CLR exception would mean + // reflecting its type into Python, which the same filters may refuse + if (e is ClrTypeFilteredException) + { + SetError(TypeError, e.Message); + return true; + } + using var instance = Converter.ToPython(e); if (instance.IsNull()) return false; diff --git a/src/runtime/IClrTypeFilter.cs b/src/runtime/IClrTypeFilter.cs new file mode 100644 index 000000000..ff17bbb5f --- /dev/null +++ b/src/runtime/IClrTypeFilter.cs @@ -0,0 +1,26 @@ +using System; + +namespace Python.Runtime +{ + /// + /// Decides whether a CLR type may be reflected into Python. + /// + /// Filters are consulted when a type would first be presented to Python. + /// Permitted types are cached for the lifetime of the engine and not + /// re-checked; a refused type is checked again on each attempt + /// + /// + /// This is a policy hook for embedders, not a security boundary: Python code + /// holding any reflected object can still reach whatever that object's own + /// API exposes. + /// + /// + public interface IClrTypeFilter + { + /// + /// Whether may be reflected into Python. Returning + /// false causes an attempt to present the type to Python to fail. + /// + bool ShouldReflect(Type type); + } +} diff --git a/src/runtime/InteropConfiguration.cs b/src/runtime/InteropConfiguration.cs index 0cd441ebc..1d6478c60 100644 --- a/src/runtime/InteropConfiguration.cs +++ b/src/runtime/InteropConfiguration.cs @@ -14,6 +14,15 @@ internal readonly PythonBaseTypeProviderGroup pythonBaseTypeProviders /// Enables replacing base types of CLR types as seen from Python public IList PythonBaseTypeProviders => this.pythonBaseTypeProviders; + internal readonly ClrTypeFilterGroup clrTypeFilters = new(); + + /// + /// Restricts which CLR types are reflected into Python. Empty by default, + /// so every type is reflected; a type is presented to Python only if every + /// registered filter permits it. + /// + public IList ClrTypeFilters => this.clrTypeFilters; + public static InteropConfiguration MakeDefault() { return new InteropConfiguration diff --git a/src/runtime/Types/InterfaceObject.cs b/src/runtime/Types/InterfaceObject.cs index 2d82392bf..c46fb30f6 100644 --- a/src/runtime/Types/InterfaceObject.cs +++ b/src/runtime/Types/InterfaceObject.cs @@ -106,13 +106,24 @@ public static NewReference tp_getattro(BorrowedReference ob, BorrowedReference k } string? name = Runtime.GetManagedString(key); - if (name == "__implementation__") + + // Both expose the concrete object, whose type an IClrTypeFilter may + // refuse - that must become a Python exception, not unwind through it + try { - return Converter.ToPython(clrObj.inst); + if (name == "__implementation__") + { + return Converter.ToPython(clrObj.inst); + } + else if (name == "__raw_implementation__") + { + return CLRObject.GetReference(clrObj.inst); + } } - else if (name == "__raw_implementation__") + catch (Exception e) { - return CLRObject.GetReference(clrObj.inst); + Exceptions.SetError(e); + return default; } return Runtime.PyObject_GenericGetAttr(ob, key); diff --git a/src/runtime/Types/ReflectedClrType.cs b/src/runtime/Types/ReflectedClrType.cs index babdb3a9f..a250209e2 100644 --- a/src/runtime/Types/ReflectedClrType.cs +++ b/src/runtime/Types/ReflectedClrType.cs @@ -29,6 +29,12 @@ public static ReflectedClrType GetOrCreate(Type type) if (ClassManager.cache.TryGetValue(type, out var pyType)) return pyType; + // Every CLR type reaching Python passes through here, so consulting the + // filters once, on the cache miss, covers every route a type can take: + // GetType(), an API returning System.Type, a field, a return value + if (!PythonEngine.InteropConfiguration.clrTypeFilters.ShouldReflect(type)) + throw new ClrTypeFilteredException(type); + // Shared with ClassManager.cache + TypeManager._slotsHolders writes // so the multi-step type build below is atomic. lock (ClassManager._cacheCreateLock)