Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Customer>` 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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type, bool>` 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!
Expand Down
130 changes: 130 additions & 0 deletions src/Serialize.Linq.Tests/Issues/Issue151.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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
/// <see cref="Serialize.Linq.Interfaces.ITypeFilter"/> set on the <see cref="ExpressionContext"/>.
/// </summary>
[TestClass]
public class Issue151
{
private sealed class Forbidden
{
public int Value { get; set; }
}

[TestMethod]
public void AllowedExpressionRoundTripsWithFilter()
{
var serializer = new ExpressionSerializer(new JsonSerializer());

Expression<Func<int, bool>> 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<Func<int, bool>>)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<Func<Forbidden, bool>> 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<TypeNotAllowedException>(
() => serializer.DeserializeText(text, context));
Assert.AreEqual(typeof(Forbidden), ex.Type);
}

[TestMethod]
public void NamespaceAllowListPermitsMatchingTypes()
{
var serializer = new ExpressionSerializer(new JsonSerializer());

Expression<Func<Forbidden, bool>> 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<Func<Forbidden, bool>>)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<Func<Forbidden, bool>> 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<TypeNotAllowedException>(
() => serializer.DeserializeText(text, context));
}

[TestMethod]
public void NoFilterAllowsAllTypes()
{
var serializer = new ExpressionSerializer(new JsonSerializer());

Expression<Func<Forbidden, bool>> expression = f => f.Value > 5;
var text = serializer.SerializeText(expression);

// Default context: no filter -> everything resolves as before.
var actual = (Expression<Func<Forbidden, bool>>)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<Func<List<Forbidden>, 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<TypeNotAllowedException>(
() => serializer.DeserializeText(text, context));
}
}
}
14 changes: 13 additions & 1 deletion src/Serialize.Linq/ExpressionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Assembly> GetAssemblies()
{
return _assemblyLoader.GetAssemblies();
Expand Down
28 changes: 22 additions & 6 deletions src/Serialize.Linq/ExpressionContextBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ protected ExpressionContextBase()

public bool AllowPrivateFieldAccess { get; set; }

/// <summary>
/// Optional allow-list applied to every type resolved during deserialization.
/// When set, any resolved type rejected by the filter causes a
/// <see cref="TypeNotAllowedException"/> to be thrown. This is the equivalent of
/// <c>BinaryFormatter</c>'s <c>SerializationBinder</c> for guarding which types
/// may be reconstructed from untrusted payloads. <c>null</c> (the default) allows all types.
/// </summary>
public ITypeFilter TypeFilter { get; set; }

public virtual BindingFlags? GetBindingFlags()
{
if (!AllowPrivateFieldAccess)
Expand All @@ -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<Assembly> GetAssemblies();
Expand Down
24 changes: 24 additions & 0 deletions src/Serialize.Linq/Interfaces/ITypeFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;

namespace Serialize.Linq.Interfaces
{
/// <summary>
/// Controls which <see cref="Type"/>s are allowed to be resolved while an expression
/// tree is being deserialized. This is the Serialize.Linq equivalent of
/// <c>BinaryFormatter</c>'s <c>SerializationBinder</c>: 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. <c>List&lt;Customer&gt;</c> is checked as <c>List&lt;&gt;</c> and <c>Customer</c>).
/// </summary>
public interface ITypeFilter
{
/// <summary>
/// Determines whether the given <paramref name="type"/> may be resolved during deserialization.
/// </summary>
/// <param name="type">The resolved type. Never <c>null</c>.</param>
/// <returns><c>true</c> to allow the type; <c>false</c> to reject it.</returns>
bool IsAllowed(Type type);
}
}
8 changes: 4 additions & 4 deletions src/Serialize.Linq/Serialize.Linq.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PublicSign Condition=" '$(OS)' != 'Windows_NT' ">true</PublicSign>
<PackageTags>linq;serialize</PackageTags>
<PackageReleaseNotes>
- support for .NET 10.0
- restrict types allowed during deserialization via ITypeFilter on ExpressionContext
</PackageReleaseNotes>
<PackageProjectUrl>https://github.com/esskar/Serialize.Linq</PackageProjectUrl>
<PackageLicenseUrl></PackageLicenseUrl>
Expand All @@ -29,9 +29,9 @@

<PropertyGroup>
<TargetFrameworks>net48;net481;net6.0;net7.0;net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<Version>4.2.0</Version>
<AssemblyVersion>4.2.0.0</AssemblyVersion>
<FileVersion>4.2.0.0</FileVersion>
<Version>4.3.0</Version>
<AssemblyVersion>4.3.0.0</AssemblyVersion>
<FileVersion>4.3.0.0</FileVersion>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>

Expand Down
11 changes: 11 additions & 0 deletions src/Serialize.Linq/Serializers/ExpressionSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExpressionNode>(stream);
return node?.ToExpression(context);
}

public string SerializeText(Expression expression, FactorySettings factorySettings = null)
{
return TextSerializer.Serialize(Convert(expression, factorySettings ?? _factorySettings));
Expand Down
Loading
Loading