LsMsgPack and LtMsgPack support polymorphic object models out of the box: when a value's type differs from its declared type, a type id is written, and when reading, that type id picks the class to create (see Schema, type ids and polymorphic class hierarchies). This document explains what that means when you read data from a source you don't trust, what the serializers do to protect you, and what you should do yourself.
- The threat
- What the schema hashes do and don't protect
- Protection built in
- The type guard
- Declare the types you expect
- How objects are created
- What else to keep in mind
When data picks the type, whoever writes the data picks the code that runs while it's read. Creating an instance runs the type's constructor, and filling in its properties runs their setters. Some framework and library types do dangerous things in those places: they start processes, load assemblies, open files or call methods named by their properties. These are known as gadgets. This is the same class of vulnerability as BinaryFormatter, Json.NET with TypeNameHandling, or XmlSerializer with types from the input.
Two things decide how exposed you are:
- Which types the data can name. A type id is resolved by name (see Polymorphic class-hierarchy support): the framework's core library, the framework's collection and numerics assemblies (and
System.Collections.Immutablewhen the application has it), and every assembly that has been cached so far (your root types' assemblies and the assemblies of the types they reach through generic arguments, base classes and public properties, the assemblies of declared types, what you registered withCacheAssemblyTypes, and what anAllowedTypesGuardallows).WildGooseChaseResolverextends this to every assembly loaded in the process. - Where the data can use them. A type id is only consulted where a value is read, and the declared type of that place (a property, a collection element or the root you deserialize) limits what fits there.
A cached schema is identified by a hash of its contents (SchemaId, a truncated SHA-256). A schema that's cached under an id can't be swapped for another schema with the same id, so a client can't make the server read later messages with a schema it planted (schema poisoning).
The hash says nothing about whether the data can be trusted. Anyone can send a schema inline and name any type in it, and anyone can write a type id into the body. The protections below apply to every way a type gets picked: type names written without a schema, type names in an inline or cached schema, and types returned by your own type resolvers (including resolving by signature).
These checks are always on, in both serializers. They have no settings.
A type picked by the data must be assignable to the declared type. If it isn't, deserializing throws a MsgPackException before an instance is created:
The type id "Process" resolves to System.Diagnostics.Process, which cannot be assigned to MyApp.IShape. Deserializing stops before creating it (see docs/security.md).
If this check did not exist, the type would be created and its setters would be called, and only assigning the finished object to the property would fail. By then, the constructor and setters of a gadget would have already run.
This makes your declared types the boundary: a property declared as IShape only accepts implementations of IShape. For a property declared as object, an interface that many types implement, or a base class that isn't yours, this check doesn't help much. Use the type guard for those.
Type names that contain an assembly name or generic arguments in brackets (System.Diagnostics.Process, System.Diagnostics.Process or List`1[[MyApp.Foo, MyApp]]) are refused with a MsgPackException. Type.GetType and Assembly.GetType would load the assembly they name, and loading an assembly runs its module initializer. The serializers never write such names: they write Name, Namespace.Name (AddTypeIdOption.FullName), List<Foo> and Foo[]. This applies to names in a schema as well, which are resolved when the schema is read.
If your own type resolver writes and reads names like these, it still can: custom resolvers are asked before the built-in name lookup.
A name in the data never resolves to an internal or private type of the framework (the core library, System.* and Microsoft.* assemblies). Those types share short names with public ones: the short name Complex would otherwise resolve to an internal struct of System.Private.CoreLib instead of System.Numerics.Complex. The types of your own assemblies are found whether they're public or not.
System.Type (and other reflection types) are never written or read: a Type in the data would be a type picked by the data. Serializing one throws a MsgPackException; write its name and resolve it in your application against the types you expect.
settings.TypeGuard = new AllowedTypesGuard()
.AllowAssemblyOf(typeof(IShape)) // all types in your model's assembly
.Allow(typeof(PluginShape)); // and single types from elsewhereMsgPackOptions.TypeGuard (IMsgPackTypeGuard, null by default) decides which types the data may pick. It's consulted after the type resolvers and the indexed schema have resolved the type, and before anything creates it. If the guard refuses the type, deserializing throws a MsgPackException that names the type and the property. It's part of the options, so it works the same in LsMsgPack (MsgPackSettings), LtMsgPack (LtMsgPackOptions) and the web formatters. Use MsgPackOptions.Default_TypeGuard to set it for all settings created afterwards.
The guard is only asked when the data picks a different type than the declared one. Values of their declared type are never checked, even with AddTypeIdOption.Always.
The included allow-list allows:
- the types you pass to
Allow, and all types of the assemblies you pass toAllowAssembly/AllowAssemblyOf; - the values the serializers write themselves: primitives, enums,
string,decimal,DateTime,DateTimeOffset,TimeSpan,Guid,Uri,DateOnly,TimeOnly,Half,Version,StringBuilder,CultureInfo,Rune,Complex,BigInteger,Int128,UInt128,Memory<byte>,ReadOnlyMemory<byte>,ArraySegment<byte>, andobject; - the framework's collections (
List<T>,Dictionary<TKey, TValue>,HashSet<T>,Queue<T>, the concurrent collections,ObservableCollection<T>, ..., andArrayListandHashtable), tuples (Tuple<...>,ValueTuple<...>) and arrays of any rank.
Generic types and arrays are only allowed when their arguments are. A type id List<Process> on a property declared as object is refused unless Process is allowed. The elements of that list are created as its declared element type, without type ids of their own, so the list's type is the only chance to stop them. Allow(typeof(Envelope<>)) allows Envelope<T> for every allowed T. Allow(typeof(Envelope<Foo>)) allows exactly that type.
What it allows becomes known by name. The data can name the types of the allowed assemblies and the allowed types without CacheAssemblyTypes. The name caches are global, so other settings (with other guards, or none) can resolve those names too; their declared types and guards still decide.
Decisions are cached per type, so a polymorphic value costs one dictionary lookup. Configure the guard before you use it. Adding types later is safe (it clears the cache), and one guard can be shared by several settings and threads. To add rules of your own, override Decide.
public class ShapeGuard : IMsgPackTypeGuard
{
public bool IsAllowed(Type type, Type assignedTo, FullPropertyInfo assignedToProp, MsgPackOptions settings)
{
return type.Namespace == "MyApp.Shapes" && type.Assembly == typeof(IShape).Assembly;
}
}assignedTo is the declared type, and assignedToProp is the property (null for the root, collection elements and dictionary entries). The type is always assignable to assignedTo, because that's checked before the guard is asked.
- The data comes from outside your trust boundary: request bodies of a web API, messages from clients, files that users upload, queues that others can write to.
- And your model has places where the declared type accepts more than you want:
object,List<object>,Dictionary<string, object>, non-generic collections, broad interfaces (IEnumerable,IDisposable,ISerializable...), or base classes of other libraries. - Or you use
WildGooseChaseResolver, which can find types in every loaded assembly.
The analyzers point out such places in the classes you serialize (LSMP004) and the calls and controller actions that read into them (LSMP005).
Data that only your own code writes and reads, such as files, caches or queues that nobody else can write to, doesn't need a guard.
The web formatters read request bodies, so they read data from clients. Set the guard on the options of the media types you accept:
AllowedTypesGuard guard = new AllowedTypesGuard().AllowAssemblyOf(typeof(Invoice));
LtMsgPackHttpOptions options = new LtMsgPackHttpOptions();
options.XLsMsgPack.TypeGuard = guard;
options.Plain.TypeGuard = guard;Alternatively, set MsgPackOptions.Default_TypeGuard before the formatters are created.
The declared types of your model are your first line of defense, and they cost nothing:
- Avoid
object,List<object>,object[]andDictionary<string, object>for data you don't trust. Every element of aList<object>can carry its own type id, and every type the resolvers can find is assignable toobject. Each element is then created as whatever type the sender named. DeclaringList<IShape>limits the elements to implementations ofIShape. DeclaringList<Square>doesn't accept type ids for other types at all. - The same goes for the root:
Deserialize<object>(bytes)lets the data choose the type of the root. Deserialize the type you expect. - Prefer your own interfaces and base classes over framework interfaces. Many unrelated framework types implement
IEnumerableorIDisposable. - If you do need
object(e.g. a property bag), add a type guard.
To find such places in your data, open it in the MsgPack Explorer (the application, the Visual Studio visualizer, the Fiddler inspector or the VS Code extension) or decode it with the MCP server. Their validation lists a security vulnerability where the data shows that a value is declared as object (or a framework interface):
- a value that is not an object or a collection with a type id (
{ "": "DateTime", "@": ... }):DateTime,Guid, enums and other such types have no derived types, so they only get a type id where the declared type isobjector an interface; - a framework type with a type id (
List<Object>,Dictionary<String, Int32>...): no class of yours derives from it; - one property of a type (with a type id, so the type is known) or one dictionary holding values of different kinds, such as an integer and a string;
- type names a reader refuses (an assembly name or bracketed generic arguments) and names of framework types known as gadgets (
Process,ObjectDataProvider...), in the schema or in a type id: data that was possibly made to attack a reader.
Integers and strings never get a type id, so an object property that only ever holds them doesn't show up. Data written with AddTypeIdOption.Always gets type ids on values of their declared type as well, so the first two points don't apply to it.
MsgPackOptions.ObjectCreation decides how both serializers create the objects they fill in:
| Choice | Constructor | Without a parameterless constructor |
|---|---|---|
Constructor |
The parameterless one (public or not), its exceptions are passed on | MsgPackException |
ConstructorOrUninitialized (default) |
The parameterless one, its exceptions are passed on | Created uninitialized |
Uninitialized |
Never | Created uninitialized |
A class created by the constructor that takes the values of its properties (immutable classes, a record with positional parameters, a class whose only public constructor takes its properties) is created by that constructor with Constructor and ConstructorOrUninitialized. With Uninitialized too, when the constructor sets a property without a public setter.
The finalizer of an object created uninitialized is always suppressed (GC.SuppressFinalize).
An uninitialized object (RuntimeHelpers.GetUninitializedObject) has all its fields zero. Its constructor doesn't run, and neither do the initializers of its fields and properties, so a property that isn't in the data stays null or 0 instead of getting the value the class gives it. DataContractSerializer creates [DataContract] types this way, while System.Text.Json, Json.NET and XmlSerializer always run a constructor.
Uninitialized could make reading faster on .NET Framework (untested), but not on .NET (Core).
Why the finalizer is suppressed: an object that was never constructed still gets finalized, and some finalizers fail on such an object. System.Threading.PeriodicTimer throws a NullReferenceException in its finalizer, which ends the process. It's found by its short name, so before this setting, a 23-byte message to a model with an object property ended the process at the next garbage collection (tested with both serializers on .NET 8).
Weak references (WeakReference, WeakReference<T> and classes derived from them) are never created without their constructor, in any mode. The garbage collector cleans them up itself, ignores GC.SuppressFinalize, and crashes the process on an uninitialized one (a segmentation fault on .NET 8, 9 and 10). Reading one throws a MsgPackException.
A type guard refuses all such framework types before they're created. Of the 12 public CoreLib classes with a finalizer, these were the only ones that ended the process when created uninitialized.
Collections and dictionaries always use their constructor when they have one, in every mode, because an uninitialized collection doesn't work. With Constructor, a collection without a parameterless constructor (and without a constructor taking its elements) is refused too.
- Your own setters run with the sender's values. Allowing a type means its constructor and setters may run with any values. Validate in setters that do more than store a value, or validate the object after deserializing.
- Non-public properties are not set by default. With
NonPublicProperties.All(see Non-public properties) the data can set every property of the classes it's read into, also the private ones a class keeps to itself;NonPublicProperties.Attributedonly sets the ones you marked ([JsonInclude],[DataMember]...). - Types without a parameterless constructor are created without running a constructor, by default without their finalizer. See How objects are created.
- Static properties are left out by
FilterStatic, one of the defaultStaticFilters, so the data can't change state that the whole application shares. If you replace the static filters, keepFilterStaticin the list. Without it, settable public static properties are written with every instance and set again when reading. - Deep nesting: both serializers refuse data with arrays and maps nested deeper than
MsgPackOptions.MaxDepth(256 by default) and throw aMsgPackException. Without a limit, a few kilobytes of nested arrays exhaust the stack, and a stack overflow ends the process. Writing stops at the same depth, so an object graph with a cycle throws instead of overflowing the stack. - Claimed lengths: a header of a few bytes can claim billions of items or bytes. Both serializers check a length against the rest of the data before they allocate anything for it, and throw a
MsgPackException("The data claims ... items, more than the remaining ... bytes can hold.", or "Unexpected end of data." for strings, bin and extensions). LsMsgPack can only check this on a stream that knows its length (CanSeek): read untrusted data from a byte array or aMemoryStream, not directly from a network stream. - Other resource use (such as payloads that are large but valid) is a separate topic. A
SchemaStorecaches at mostMaxSchemasreceived schemas. - Custom type resolvers are your code: a resolver that maps names to types decides what the data can reach. The guard is still asked about the types it returns.