From 3e77ab115e5a776997e6d556ef64c49bdc2a5f76 Mon Sep 17 00:00:00 2001 From: Sascha Kiefer Date: Tue, 16 Jun 2026 10:41:40 +0200 Subject: [PATCH] feat: restrict types allowed during deserialization via ITypeFilter Add an opt-in type allow-list for expression deserialization, the equivalent of BinaryFormatter's SerializationBinder, so untrusted payloads cannot resolve arbitrary types. - ITypeFilter extension point on ExpressionContext (TypeFilter property + constructor overloads); enforced in ExpressionContextBase.ResolveType - Built-in AllowedTypesFilter (allow-list by type/open-generic/namespace) and DelegateTypeFilter (predicate) - TypeNotAllowedException thrown for rejected types - Filter applied on cache hits and to each generic argument, so it cannot be bypassed; no filter set keeps existing behavior unchanged - Add ExpressionSerializer.Deserialize(Stream, IExpressionContext) overload - Tests in Issues/Issue151.cs (90/90 passing) - Bump version to 4.3.0; update CHANGELOG and README Closes #151 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 +++ README.md | 24 ++++ src/Serialize.Linq.Tests/Issues/Issue151.cs | 130 ++++++++++++++++++ src/Serialize.Linq/ExpressionContext.cs | 14 +- src/Serialize.Linq/ExpressionContextBase.cs | 28 +++- src/Serialize.Linq/Interfaces/ITypeFilter.cs | 24 ++++ src/Serialize.Linq/Serialize.Linq.csproj | 8 +- .../Serializers/ExpressionSerializer.cs | 11 ++ .../TypeFilters/AllowedTypesFilter.cs | 80 +++++++++++ .../TypeFilters/DelegateTypeFilter.cs | 24 ++++ src/Serialize.Linq/TypeNotAllowedException.cs | 29 ++++ 11 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 src/Serialize.Linq.Tests/Issues/Issue151.cs create mode 100644 src/Serialize.Linq/Interfaces/ITypeFilter.cs create mode 100644 src/Serialize.Linq/TypeFilters/AllowedTypesFilter.cs create mode 100644 src/Serialize.Linq/TypeFilters/DelegateTypeFilter.cs create mode 100644 src/Serialize.Linq/TypeNotAllowedException.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c409373..fe25f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This changelog was reconstructed from the project's commit history and published NuGet releases; entries for older versions are a best-effort summary. +## [4.3.0] - 2026-06-16 + +### Added +- `ITypeFilter` extension point on `ExpressionContext` to restrict which types may be + resolved while deserializing an expression tree — the equivalent of `BinaryFormatter`'s + `SerializationBinder` for guarding against untrusted payloads. A rejected type throws + `TypeNotAllowedException`. Built-in implementations `AllowedTypesFilter` (allow-list by + type/open-generic/namespace) and `DelegateTypeFilter` (predicate) are provided, plus new + `ExpressionContext` constructor overloads accepting an `ITypeFilter` and a + `Deserialize(Stream, IExpressionContext)` overload ([#151]). + +### Notes +- The filter is consulted for every resolved type, including the open generic definition + and each generic argument of a closed generic (e.g. `List` is checked as + `List<>` and `Customer`). It is applied on cache hits too, so a restricted context cannot + be bypassed. With no filter set (the default), behavior is unchanged and all types resolve. + ## [4.2.0] - 2026-06-16 ### Added @@ -115,6 +132,7 @@ NuGet releases; entries for older versions are a best-effort summary. [Unreleased]: https://github.com/esskar/Serialize.Linq/compare/main...HEAD [#178]: https://github.com/esskar/Serialize.Linq/issues/178 +[#151]: https://github.com/esskar/Serialize.Linq/issues/151 [#169]: https://github.com/esskar/Serialize.Linq/issues/169 [#163]: https://github.com/esskar/Serialize.Linq/pull/163 [#146]: https://github.com/esskar/Serialize.Linq/issues/146 diff --git a/README.md b/README.md index 42a6dc4..9963f58 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,30 @@ string serializedExpression = serializer.SerializeText(expression); var deserializedExpression = serializer.DeserializeText(serializedExpression); ``` +### Restricting types during deserialization + +Deserializing an expression reconstructs `Type`s by name from the payload. When the payload is +untrusted, supply an `ITypeFilter` on an `ExpressionContext` to allow-list the permitted types — +the equivalent of `BinaryFormatter`'s `SerializationBinder`. Any type the filter rejects throws a +`TypeNotAllowedException`. + +```csharp +using Serialize.Linq; +using Serialize.Linq.TypeFilters; + +// Only allow the types your expressions actually use. +var filter = new AllowedTypesFilter(typeof(int), typeof(bool), typeof(object), typeof(Func<,>)) + .AllowNamespace("MyApp.Models"); + +var context = new ExpressionContext(filter); +var deserializedExpression = serializer.DeserializeText(serializedExpression, context); +``` + +`AllowedTypesFilter` matches explicit types (use the open definition for generics, e.g. +`typeof(List<>)`) and whole namespaces; `DelegateTypeFilter` wraps an arbitrary +`Func` predicate. The filter is checked for every resolved type, including each +generic argument. With no context/filter, all types resolve as before. + ## Contributing We welcome contributions to Serialize.Linq! diff --git a/src/Serialize.Linq.Tests/Issues/Issue151.cs b/src/Serialize.Linq.Tests/Issues/Issue151.cs new file mode 100644 index 0000000..eaf2ad4 --- /dev/null +++ b/src/Serialize.Linq.Tests/Issues/Issue151.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Serialize.Linq; +using Serialize.Linq.Serializers; +using Serialize.Linq.TypeFilters; + +namespace Serialize.Linq.Tests.Issues +{ + /// + /// https://github.com/esskar/Serialize.Linq/issues/151 + /// Allow callers to restrict which types may be resolved during deserialization + /// (the Serialize.Linq equivalent of BinaryFormatter's SerializationBinder), via an + /// set on the . + /// + [TestClass] + public class Issue151 + { + private sealed class Forbidden + { + public int Value { get; set; } + } + + [TestMethod] + public void AllowedExpressionRoundTripsWithFilter() + { + var serializer = new ExpressionSerializer(new JsonSerializer()); + + Expression> expression = x => x > 5; + var text = serializer.SerializeText(expression); + + var filter = new AllowedTypesFilter( + typeof(int), typeof(bool), typeof(object), typeof(Func<,>)); + var context = new ExpressionContext(filter); + + var actual = (Expression>)serializer.DeserializeText(text, context); + + var func = actual.Compile(); + Assert.IsTrue(func(6)); + Assert.IsFalse(func(4)); + } + + [TestMethod] + public void DisallowedTypeIsRejectedDuringDeserialization() + { + var serializer = new ExpressionSerializer(new JsonSerializer()); + + Expression> expression = f => f.Value > 5; + var text = serializer.SerializeText(expression); + + // Allow-list deliberately omits Forbidden. + var filter = new AllowedTypesFilter( + typeof(int), typeof(bool), typeof(object), typeof(Func<,>)); + var context = new ExpressionContext(filter); + + var ex = Assert.ThrowsExactly( + () => serializer.DeserializeText(text, context)); + Assert.AreEqual(typeof(Forbidden), ex.Type); + } + + [TestMethod] + public void NamespaceAllowListPermitsMatchingTypes() + { + var serializer = new ExpressionSerializer(new JsonSerializer()); + + Expression> expression = f => f.Value > 5; + var text = serializer.SerializeText(expression); + + var filter = new AllowedTypesFilter( + typeof(int), typeof(bool), typeof(object), typeof(Func<,>)) + .AllowNamespace(typeof(Forbidden).Namespace); + var context = new ExpressionContext(filter); + + var actual = (Expression>)serializer.DeserializeText(text, context); + + var func = actual.Compile(); + Assert.IsTrue(func(new Forbidden { Value = 6 })); + Assert.IsFalse(func(new Forbidden { Value = 4 })); + } + + [TestMethod] + public void DelegateTypeFilterCanRejectTypes() + { + var serializer = new ExpressionSerializer(new JsonSerializer()); + + Expression> expression = f => f.Value > 5; + var text = serializer.SerializeText(expression); + + // Reject anything declared in this test assembly's namespace. + var filter = new DelegateTypeFilter(t => t != typeof(Forbidden)); + var context = new ExpressionContext(filter); + + Assert.ThrowsExactly( + () => serializer.DeserializeText(text, context)); + } + + [TestMethod] + public void NoFilterAllowsAllTypes() + { + var serializer = new ExpressionSerializer(new JsonSerializer()); + + Expression> expression = f => f.Value > 5; + var text = serializer.SerializeText(expression); + + // Default context: no filter -> everything resolves as before. + var actual = (Expression>)serializer.DeserializeText(text, new ExpressionContext()); + + var func = actual.Compile(); + Assert.IsTrue(func(new Forbidden { Value = 6 })); + } + + [TestMethod] + public void GenericArgumentsAreFiltered() + { + var serializer = new ExpressionSerializer(new JsonSerializer()); + + Expression, int>> expression = list => list.Count; + var text = serializer.SerializeText(expression); + + // List<> is allowed but its argument Forbidden is not. + var filter = new AllowedTypesFilter( + typeof(int), typeof(object), typeof(Func<,>), typeof(List<>)); + var context = new ExpressionContext(filter); + + Assert.ThrowsExactly( + () => serializer.DeserializeText(text, context)); + } + } +} diff --git a/src/Serialize.Linq/ExpressionContext.cs b/src/Serialize.Linq/ExpressionContext.cs index e01718d..ea4cfe0 100644 --- a/src/Serialize.Linq/ExpressionContext.cs +++ b/src/Serialize.Linq/ExpressionContext.cs @@ -16,10 +16,22 @@ public ExpressionContext() public ExpressionContext(IAssemblyLoader assemblyLoader) { - _assemblyLoader = assemblyLoader + _assemblyLoader = assemblyLoader ?? throw new ArgumentNullException(nameof(assemblyLoader)); } + public ExpressionContext(ITypeFilter typeFilter) + : this(new DefaultAssemblyLoader()) + { + TypeFilter = typeFilter; + } + + public ExpressionContext(IAssemblyLoader assemblyLoader, ITypeFilter typeFilter) + : this(assemblyLoader) + { + TypeFilter = typeFilter; + } + protected override IEnumerable GetAssemblies() { return _assemblyLoader.GetAssemblies(); diff --git a/src/Serialize.Linq/ExpressionContextBase.cs b/src/Serialize.Linq/ExpressionContextBase.cs index b30fa38..50b3118 100644 --- a/src/Serialize.Linq/ExpressionContextBase.cs +++ b/src/Serialize.Linq/ExpressionContextBase.cs @@ -22,6 +22,15 @@ protected ExpressionContextBase() public bool AllowPrivateFieldAccess { get; set; } + /// + /// Optional allow-list applied to every type resolved during deserialization. + /// When set, any resolved type rejected by the filter causes a + /// to be thrown. This is the equivalent of + /// BinaryFormatter's SerializationBinder for guarding which types + /// may be reconstructed from untrusted payloads. null (the default) allows all types. + /// + public ITypeFilter TypeFilter { get; set; } + public virtual BindingFlags? GetBindingFlags() { if (!AllowPrivateFieldAccess) @@ -46,21 +55,28 @@ public virtual Type ResolveType(TypeNode node) if (string.IsNullOrWhiteSpace(node.Name)) return null; - return _typeCache.GetOrAdd(node.Name, n => + var type = _typeCache.GetOrAdd(node.Name, n => { - var type = Type.GetType(n); - if (type == null) + var resolved = Type.GetType(n); + if (resolved == null) { foreach (var assembly in GetAssemblies()) { - type = assembly.GetType(n); - if (type != null) + resolved = assembly.GetType(n); + if (resolved != null) break; } } - return type; + return resolved; }); + + // Apply the allow-list on every resolution (including cache hits) so that + // restricted contexts cannot be bypassed once a type has been cached. + if (type != null && TypeFilter != null && !TypeFilter.IsAllowed(type)) + throw new TypeNotAllowedException(type); + + return type; } protected abstract IEnumerable GetAssemblies(); diff --git a/src/Serialize.Linq/Interfaces/ITypeFilter.cs b/src/Serialize.Linq/Interfaces/ITypeFilter.cs new file mode 100644 index 0000000..6869668 --- /dev/null +++ b/src/Serialize.Linq/Interfaces/ITypeFilter.cs @@ -0,0 +1,24 @@ +using System; + +namespace Serialize.Linq.Interfaces +{ + /// + /// Controls which s are allowed to be resolved while an expression + /// tree is being deserialized. This is the Serialize.Linq equivalent of + /// BinaryFormatter's SerializationBinder: it lets callers restrict + /// deserialization to a known set of types and reject everything else. + /// + /// A filter is consulted for every type encountered during deserialization, including + /// the open generic definition and each generic argument of a closed generic type + /// (e.g. List<Customer> is checked as List<> and Customer). + /// + public interface ITypeFilter + { + /// + /// Determines whether the given may be resolved during deserialization. + /// + /// The resolved type. Never null. + /// true to allow the type; false to reject it. + bool IsAllowed(Type type); + } +} diff --git a/src/Serialize.Linq/Serialize.Linq.csproj b/src/Serialize.Linq/Serialize.Linq.csproj index 5361524..22b9db0 100644 --- a/src/Serialize.Linq/Serialize.Linq.csproj +++ b/src/Serialize.Linq/Serialize.Linq.csproj @@ -11,7 +11,7 @@ true linq;serialize - - support for .NET 10.0 + - restrict types allowed during deserialization via ITypeFilter on ExpressionContext https://github.com/esskar/Serialize.Linq @@ -29,9 +29,9 @@ net48;net481;net6.0;net7.0;net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1 - 4.2.0 - 4.2.0.0 - 4.2.0.0 + 4.3.0 + 4.3.0.0 + 4.3.0.0 LICENSE diff --git a/src/Serialize.Linq/Serializers/ExpressionSerializer.cs b/src/Serialize.Linq/Serializers/ExpressionSerializer.cs index b1e1fe0..7995e16 100644 --- a/src/Serialize.Linq/Serializers/ExpressionSerializer.cs +++ b/src/Serialize.Linq/Serializers/ExpressionSerializer.cs @@ -61,6 +61,17 @@ public Expression Deserialize(Stream stream) return node?.ToExpression(); } + public Expression Deserialize(Stream stream, IExpressionContext context) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var node = _serializer.Deserialize(stream); + return node?.ToExpression(context); + } + public string SerializeText(Expression expression, FactorySettings factorySettings = null) { return TextSerializer.Serialize(Convert(expression, factorySettings ?? _factorySettings)); diff --git a/src/Serialize.Linq/TypeFilters/AllowedTypesFilter.cs b/src/Serialize.Linq/TypeFilters/AllowedTypesFilter.cs new file mode 100644 index 0000000..aafb1cd --- /dev/null +++ b/src/Serialize.Linq/TypeFilters/AllowedTypesFilter.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Serialize.Linq.Interfaces; + +namespace Serialize.Linq.TypeFilters +{ + /// + /// An allow-list : only the explicitly listed types (and, + /// optionally, types in the listed namespaces) are permitted; everything else is rejected. + /// + /// For generic types, list the open generic definition (e.g. typeof(List<>)), + /// because closed generics are resolved one component at a time during deserialization + /// (the open definition plus each generic argument). Primitive expression machinery such + /// as System.Func<>, bool and object is typically needed too. + /// + public class AllowedTypesFilter : ITypeFilter + { + private readonly HashSet _types; + private readonly HashSet _namespaces; + + public AllowedTypesFilter(params Type[] allowedTypes) + : this((IEnumerable)allowedTypes, null) { } + + public AllowedTypesFilter(IEnumerable allowedTypes, IEnumerable allowedNamespaces = null) + { + _types = new HashSet(allowedTypes ?? Enumerable.Empty()); + _namespaces = new HashSet(allowedNamespaces ?? Enumerable.Empty(), StringComparer.Ordinal); + } + + /// + /// Adds a type to the allow-list. For generic types pass the open definition, + /// e.g. typeof(Dictionary<,>). + /// + public AllowedTypesFilter Allow(Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + _types.Add(type); + return this; + } + + /// + /// Allows every type whose namespace equals or is nested under . + /// + public AllowedTypesFilter AllowNamespace(string namespacePrefix) + { + if (string.IsNullOrWhiteSpace(namespacePrefix)) + throw new ArgumentNullException(nameof(namespacePrefix)); + _namespaces.Add(namespacePrefix); + return this; + } + + public bool IsAllowed(Type type) + { + if (type == null) + return false; + + if (_types.Contains(type)) + return true; + + var typeInfo = type.GetTypeInfo(); + if (typeInfo.IsGenericType && _types.Contains(type.GetGenericTypeDefinition())) + return true; + + var ns = type.Namespace; + if (ns != null) + { + foreach (var allowed in _namespaces) + { + if (ns == allowed || ns.StartsWith(allowed + ".", StringComparison.Ordinal)) + return true; + } + } + + return false; + } + } +} diff --git a/src/Serialize.Linq/TypeFilters/DelegateTypeFilter.cs b/src/Serialize.Linq/TypeFilters/DelegateTypeFilter.cs new file mode 100644 index 0000000..752086c --- /dev/null +++ b/src/Serialize.Linq/TypeFilters/DelegateTypeFilter.cs @@ -0,0 +1,24 @@ +using System; +using Serialize.Linq.Interfaces; + +namespace Serialize.Linq.TypeFilters +{ + /// + /// An backed by a user supplied predicate. Useful for ad-hoc + /// rules, e.g. new DelegateTypeFilter(t => t.Namespace?.StartsWith("MyApp") == true). + /// + public class DelegateTypeFilter : ITypeFilter + { + private readonly Func _predicate; + + public DelegateTypeFilter(Func predicate) + { + _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + } + + public bool IsAllowed(Type type) + { + return type != null && _predicate(type); + } + } +} diff --git a/src/Serialize.Linq/TypeNotAllowedException.cs b/src/Serialize.Linq/TypeNotAllowedException.cs new file mode 100644 index 0000000..11ccabd --- /dev/null +++ b/src/Serialize.Linq/TypeNotAllowedException.cs @@ -0,0 +1,29 @@ +using System; +using Serialize.Linq.Interfaces; + +namespace Serialize.Linq +{ + /// + /// Thrown during deserialization when a is resolved that is rejected + /// by the configured . + /// + public class TypeNotAllowedException : Exception + { + /// + /// The type that was rejected, or null if it could not be determined. + /// + public Type Type { get; } + + public TypeNotAllowedException(Type type) + : base($"Deserialization of type '{type?.FullName ?? ""}' is not allowed by the configured ITypeFilter.") + { + Type = type; + } + + public TypeNotAllowedException(string message) + : base(message) { } + + public TypeNotAllowedException(string message, Exception innerException) + : base(message, innerException) { } + } +}