diff --git a/src/DurableTask.ServiceBus/Tracking/AzureTableOrchestrationHistoryEventEntity.cs b/src/DurableTask.ServiceBus/Tracking/AzureTableOrchestrationHistoryEventEntity.cs index 99272b67d..219474000 100644 --- a/src/DurableTask.ServiceBus/Tracking/AzureTableOrchestrationHistoryEventEntity.cs +++ b/src/DurableTask.ServiceBus/Tracking/AzureTableOrchestrationHistoryEventEntity.cs @@ -18,7 +18,6 @@ namespace DurableTask.ServiceBus.Tracking using System.Runtime.Serialization; using Azure.Data.Tables; using DurableTask.Core.History; - using DurableTask.Core.Serializing; using Newtonsoft.Json; /// @@ -29,13 +28,15 @@ internal class AzureTableOrchestrationHistoryEventEntity : AzureTableCompositeTa private static readonly JsonSerializerSettings WriteJsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented, + // CodeQL [SM02211] Serialization-only path; deserialization is constrained by HistoryEventSerializationBinder on ReadJsonSettings. TypeNameHandling = TypeNameHandling.Objects }; private static readonly JsonSerializerSettings ReadJsonSettings = new JsonSerializerSettings { + // CodeQL [SM02211] Constrained by HistoryEventSerializationBinder allowlist; only DurableTask.Core types and Dictionary are accepted. TypeNameHandling = TypeNameHandling.Objects, - SerializationBinder = new PackageUpgradeSerializationBinder() + SerializationBinder = new HistoryEventSerializationBinder() }; /// diff --git a/src/DurableTask.ServiceBus/Tracking/HistoryEventSerializationBinder.cs b/src/DurableTask.ServiceBus/Tracking/HistoryEventSerializationBinder.cs new file mode 100644 index 000000000..cf001175e --- /dev/null +++ b/src/DurableTask.ServiceBus/Tracking/HistoryEventSerializationBinder.cs @@ -0,0 +1,126 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.ServiceBus.Tracking +{ + using System; + using System.Collections.Generic; + using System.Reflection; + using DurableTask.Core.History; + using DurableTask.Core.Serializing; + using Newtonsoft.Json; + + /// + /// Strict used when deserializing + /// orchestration history events stored in Azure Table tracking. Only types that the framework + /// itself emits when serializing a are accepted; any other + /// $type token is rejected with a . + /// + /// + /// This binder is intentionally restrictive: the history event JSON written by DTFx never + /// contains polymorphic customer types (customer payloads such as Input/Result/ + /// Reason/Details are pre-serialized strings on the + /// subtree and are opaque to this serializer). Restricting the resolvable type set defends + /// against unsafe-deserialization gadget chains if a malicious $type were ever written + /// into the tracking table by an attacker with write access to the Storage account. + /// + internal sealed class HistoryEventSerializationBinder : PackageUpgradeSerializationBinder + { + static readonly Assembly DurableTaskCoreAssembly = typeof(HistoryEvent).Assembly; + + // Allowlist of simple assembly names whose types may even be resolved by this binder. + // This is a defense-in-depth check applied *before* delegating to the base binder, so + // that the .NET runtime never has to load (and execute the module initializer of) an + // assembly that is not on the allowlist as a side effect of probing a malicious $type. + // The set spans: + // * DurableTask.Core (the source of HistoryEvent and friends). + // * The legacy pre-v2 DTFx assembly names rewritten by PackageUpgradeSerializationBinder. + // * The BCL assemblies that host common IDictionary implementations + // emitted as $type for the Tags member. The host assembly varies by TFM and by + // concrete dictionary type, so we enumerate them via typeof(...).Assembly to stay + // correct under multi-targeting. + static readonly HashSet AllowedAssemblySimpleNames = new HashSet(StringComparer.Ordinal) + { + DurableTaskCoreAssembly.GetName().Name, // DurableTask.Core + typeof(Dictionary).Assembly.GetName().Name, // mscorlib / System.Private.CoreLib + typeof(SortedDictionary).Assembly.GetName().Name, // System / System.Collections + typeof(System.Collections.Concurrent.ConcurrentDictionary).Assembly.GetName().Name, // mscorlib / System.Collections.Concurrent + typeof(System.Collections.ObjectModel.ReadOnlyDictionary).Assembly.GetName().Name, // mscorlib / System.ObjectModel + "DurableTask", // pre-v2 DTFx assembly (legacy upgrade path) + "DurableTaskFx", // pre-v2 DTFx vNext assembly (legacy upgrade path) + }; + + /// + public override Type BindToType(string assemblyName, string typeName) + { + // Stage 1: reject by assembly name string before invoking the base binder so that + // unknown assemblies are never loaded just to be rejected afterwards. + // Reject null/empty assembly names deterministically. Json.NET can invoke + // BindToType with a null assemblyName when an incoming $type token omits the + // assembly portion; an unqualified type name is never produced by DTFx + // serialization and would also cause the base PackageUpgradeSerializationBinder + // to throw a NullReferenceException, so fail fast with a typed exception here. + string simpleAssemblyName = ExtractSimpleAssemblyName(assemblyName); + if (simpleAssemblyName == null || !AllowedAssemblySimpleNames.Contains(simpleAssemblyName)) + { + throw new JsonSerializationException( + $"Type '{typeName}' from assembly '{assemblyName}' is not permitted by the orchestration history serialization binder."); + } + + // Stage 2: delegate to PackageUpgradeSerializationBinder for the legacy + // DurableTask.* -> DurableTask.Core.* rewrite and standard type resolution. + Type resolved = base.BindToType(assemblyName, typeName); + + // Stage 3: filter the resolved type. The BCL is in the assembly allowlist (so that + // Dictionary can be round-tripped for the Tags member), so we still + // need this post-resolution check to reject other BCL types (e.g., FileInfo, + // ObjectDataProvider) that an attacker might try to use as a deserialization gadget. + if (resolved == null || !IsAllowed(resolved)) + { + throw new JsonSerializationException( + $"Type '{typeName}' from assembly '{assemblyName}' is not permitted by the orchestration history serialization binder."); + } + + return resolved; + } + + static bool IsAllowed(Type type) + { + // Allow any type defined in DurableTask.Core (HistoryEvent subclasses, OrchestrationInstance, + // ParentInstance, FailureDetails, OrchestrationExecutionContext, etc.). + if (type.Assembly == DurableTaskCoreAssembly) + { + return true; + } + + // For all other allowlisted assemblies (the BCL dictionary hosts plus the legacy + // pre-v2 DTFx assemblies), only types assignable to IDictionary + // are accepted. This narrows the resolved set so that gadget types living in the + // same allowlisted BCL assembly (e.g., FileInfo in System.Private.CoreLib) cannot + // pass the post-resolution check. Tags is the only IDictionary + // member declared on a serialized HistoryEvent subtree, so any other concrete + // type is unreachable by the legitimate serializer in any case. + return typeof(IDictionary).IsAssignableFrom(type); + } + + static string ExtractSimpleAssemblyName(string assemblyName) + { + if (string.IsNullOrWhiteSpace(assemblyName)) + { + return null; + } + int comma = assemblyName.IndexOf(','); + return comma < 0 ? assemblyName : assemblyName.Substring(0, comma); + } + } +} diff --git a/test/DurableTask.ServiceBus.Tests/HistoryEventSerializationBinderTests.cs b/test/DurableTask.ServiceBus.Tests/HistoryEventSerializationBinderTests.cs new file mode 100644 index 000000000..405862f64 --- /dev/null +++ b/test/DurableTask.ServiceBus.Tests/HistoryEventSerializationBinderTests.cs @@ -0,0 +1,187 @@ +// ---------------------------------------------------------------------------------- +// Copyright Microsoft Corporation +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ---------------------------------------------------------------------------------- + +namespace DurableTask.ServiceBus.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using DurableTask.Core; + using DurableTask.Core.History; + using DurableTask.ServiceBus.Tracking; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json; + + [TestClass] + public class HistoryEventSerializationBinderTests + { + // Mirrors the production read settings on AzureTableOrchestrationHistoryEventEntity. + static readonly JsonSerializerSettings ReadJsonSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.Objects, + SerializationBinder = new HistoryEventSerializationBinder() + }; + + // Settings used to *produce* the JSON exactly as the production code does. + static readonly JsonSerializerSettings WriteJsonSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.Objects + }; + + [TestMethod] + public void RoundTripsExecutionStartedEventWithTags() + { + var original = new ExecutionStartedEvent(eventId: -1, input: "input") + { + OrchestrationInstance = new OrchestrationInstance + { + InstanceId = "instance-1", + ExecutionId = "execution-1", + }, + Name = "OrchestrationName", + Version = "1.0", + Tags = new Dictionary { ["tag1"] = "value1" }, + }; + + string json = JsonConvert.SerializeObject(original, WriteJsonSettings); + var deserialized = JsonConvert.DeserializeObject(json, ReadJsonSettings); + + Assert.IsInstanceOfType(deserialized, typeof(ExecutionStartedEvent)); + var deserializedStarted = (ExecutionStartedEvent)deserialized; + Assert.AreEqual("OrchestrationName", deserializedStarted.Name); + Assert.AreEqual("input", deserializedStarted.Input); + Assert.AreEqual("value1", deserializedStarted.Tags["tag1"]); + } + + [TestMethod] + public void AllowsAllConcreteHistoryEventTypes() + { + // Sanity check that every concrete HistoryEvent subclass declared in DurableTask.Core + // is accepted by the binder's BindToType. + var binder = new HistoryEventSerializationBinder(); + foreach (Type concreteType in HistoryEvent.KnownTypes()) + { + Type bound = binder.BindToType(concreteType.Assembly.GetName().Name, concreteType.FullName); + Assert.AreEqual(concreteType, bound); + } + } + + [TestMethod] + public void RejectsNonAllowlistedRootType() + { + // System.IO.FileInfo is a classic gadget-chain probe. The binder must reject it + // even though the BCL would happily resolve it via Type.GetType. FileInfo lives + // in the BCL (which is on the assembly-name allowlist so that Dictionary can round-trip), so this test specifically exercises the post-resolution + // IsAllowed filter rather than the assembly-name pre-filter. + // The BCL assembly name differs between TFMs (System.Private.CoreLib on .NET, + // mscorlib on .NET Framework), so build the $type string from the actual runtime + // type to avoid passing for the wrong reason (type-resolution failure). + string bclAssemblyName = typeof(FileInfo).Assembly.GetName().Name; + string json = $"{{\"$type\":\"System.IO.FileInfo, {bclAssemblyName}\",\"OriginalPath\":\"c:\\\\evil\"}}"; + Assert.ThrowsException( + () => JsonConvert.DeserializeObject(json, ReadJsonSettings)); + } + + [TestMethod] + public void RejectsNonAllowlistedNestedType() + { + // Embed a malicious $type inside an otherwise valid ExecutionStartedEvent's Tags + // member. The Tags member is IDictionary, so the $type token is + // honored by Newtonsoft.Json when reading. The binder must reject any concrete + // type that is not assignable to IDictionary even when it lives in + // an allowlisted assembly. System.Collections.Hashtable is in the BCL (so the + // assembly-name pre-filter does not apply) but is not IDictionary, + // so this exercises the post-resolution IsAllowed filter. + string bclAssemblyName = typeof(System.Collections.Hashtable).Assembly.GetName().Name; + string json = + "{\"$type\":\"DurableTask.Core.History.ExecutionStartedEvent, DurableTask.Core\"," + + $"\"Tags\":{{\"$type\":\"System.Collections.Hashtable, {bclAssemblyName}\"}}}}"; + Assert.ThrowsException( + () => JsonConvert.DeserializeObject(json, ReadJsonSettings)); + } + + [TestMethod] + public void RejectsNonBclDictionaryAssembly() + { + // Even an IDictionary implementation must originate from an + // allowlisted assembly. Construct a $type referencing a fictitious assembly to + // confirm the assembly-name pre-filter rejects it before any type loading occurs. + string json = + "{\"$type\":\"DurableTask.Core.History.ExecutionStartedEvent, DurableTask.Core\"," + + "\"Tags\":{\"$type\":\"Some.Evil.Dictionary, Some.Evil.Assembly\"}}"; + Assert.ThrowsException( + () => JsonConvert.DeserializeObject(json, ReadJsonSettings)); + } + + [TestMethod] + public void RejectsNullAssemblyName() + { + // Json.NET can invoke BindToType with a null assemblyName when an incoming $type + // token omits the assembly portion. The binder must fail deterministically with a + // JsonSerializationException rather than letting a NullReferenceException leak out + // of PackageUpgradeSerializationBinder. + var binder = new HistoryEventSerializationBinder(); + Assert.ThrowsException( + () => binder.BindToType(assemblyName: null, typeName: "DurableTask.Core.History.ExecutionStartedEvent")); + } + + [TestMethod] + public void AllowsDictionaryStringStringForTags() + { + // The exact concrete type Newtonsoft.Json emits for IDictionary Tags. + string bclAssemblyName = typeof(string).Assembly.GetName().Name; + string dictAssemblyName = typeof(Dictionary).Assembly.GetName().Name; + string json = + "{\"$type\":\"DurableTask.Core.History.ExecutionStartedEvent, DurableTask.Core\"," + + $"\"Tags\":{{\"$type\":\"System.Collections.Generic.Dictionary`2[[System.String, {bclAssemblyName}],[System.String, {bclAssemblyName}]], {dictAssemblyName}\"," + + "\"tag1\":\"v1\"}}"; + + var deserialized = (ExecutionStartedEvent)JsonConvert.DeserializeObject(json, ReadJsonSettings); + Assert.AreEqual("v1", deserialized.Tags["tag1"]); + } + + [TestMethod] + public void AllowsSortedDictionaryStringStringForTags() + { + // Public DTFx APIs accept any IDictionary for Tags and persist it + // with its runtime $type. SortedDictionary lives in a different BCL assembly than + // Dictionary on .NET (System.Collections), so this test exercises the multi-host + // assembly allowlist together with the IDictionary-shape post-check. + string bclAssemblyName = typeof(string).Assembly.GetName().Name; + string sortedDictAssemblyName = typeof(SortedDictionary).Assembly.GetName().Name; + string json = + "{\"$type\":\"DurableTask.Core.History.ExecutionStartedEvent, DurableTask.Core\"," + + $"\"Tags\":{{\"$type\":\"System.Collections.Generic.SortedDictionary`2[[System.String, {bclAssemblyName}],[System.String, {bclAssemblyName}]], {sortedDictAssemblyName}\"," + + "\"tag1\":\"v1\"}}"; + + var deserialized = (ExecutionStartedEvent)JsonConvert.DeserializeObject(json, ReadJsonSettings); + Assert.AreEqual("v1", deserialized.Tags["tag1"]); + } + + [TestMethod] + public void AllowsLegacyDurableTaskAssemblyNameRewrite() + { + // Pre-v2 DTFx payloads were written with assembly name 'DurableTask' (or + // 'DurableTaskFx') and a 'DurableTask.' type name. PackageUpgradeSerializationBinder + // (the base class) rewrites these to 'DurableTask.Core.' for upgrade compatibility. + // This test guards that path: HistoryEventSerializationBinder must keep the legacy + // assembly name on its allowlist and must accept the rewritten type after resolution. + var binder = new HistoryEventSerializationBinder(); + Type bound = binder.BindToType( + assemblyName: "DurableTask", + typeName: "DurableTask.History.ExecutionStartedEvent"); + Assert.AreEqual(typeof(ExecutionStartedEvent), bound); + } + } +}