Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
Expand All @@ -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<string,string> are accepted.
TypeNameHandling = TypeNameHandling.Objects,
SerializationBinder = new PackageUpgradeSerializationBinder()
SerializationBinder = new HistoryEventSerializationBinder()
};

/// <summary>
Expand Down
126 changes: 126 additions & 0 deletions src/DurableTask.ServiceBus/Tracking/HistoryEventSerializationBinder.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Strict <see cref="Newtonsoft.Json.Serialization.ISerializationBinder"/> used when deserializing
/// orchestration history events stored in Azure Table tracking. Only types that the framework
/// itself emits when serializing a <see cref="HistoryEvent"/> are accepted; any other
/// <c>$type</c> token is rejected with a <see cref="JsonSerializationException"/>.
/// </summary>
/// <remarks>
/// This binder is intentionally restrictive: the history event JSON written by DTFx never
/// contains polymorphic customer types (customer payloads such as <c>Input</c>/<c>Result</c>/
/// <c>Reason</c>/<c>Details</c> are pre-serialized strings on the <see cref="HistoryEvent"/>
/// subtree and are opaque to this serializer). Restricting the resolvable type set defends
/// against unsafe-deserialization gadget chains if a malicious <c>$type</c> were ever written
/// into the tracking table by an attacker with write access to the Storage account.
/// </remarks>
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<string, string> 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<string> AllowedAssemblySimpleNames = new HashSet<string>(StringComparer.Ordinal)
{
DurableTaskCoreAssembly.GetName().Name, // DurableTask.Core
typeof(Dictionary<string, string>).Assembly.GetName().Name, // mscorlib / System.Private.CoreLib
typeof(SortedDictionary<string, string>).Assembly.GetName().Name, // System / System.Collections
typeof(System.Collections.Concurrent.ConcurrentDictionary<string, string>).Assembly.GetName().Name, // mscorlib / System.Collections.Concurrent
typeof(System.Collections.ObjectModel.ReadOnlyDictionary<string, string>).Assembly.GetName().Name, // mscorlib / System.ObjectModel
"DurableTask", // pre-v2 DTFx assembly (legacy upgrade path)
"DurableTaskFx", // pre-v2 DTFx vNext assembly (legacy upgrade path)
};

/// <inheritdoc />
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<string,string> 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<string, string>
// 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<string, string>
// member declared on a serialized HistoryEvent subtree, so any other concrete
// type is unreachable by the legitimate serializer in any case.
return typeof(IDictionary<string, string>).IsAssignableFrom(type);
}
Comment on lines +97 to +114
Comment on lines +106 to +114

static string ExtractSimpleAssemblyName(string assemblyName)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
return null;
}
int comma = assemblyName.IndexOf(',');
return comma < 0 ? assemblyName : assemblyName.Substring(0, comma);
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
};
Comment on lines +25 to +33

// 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<string, string> { ["tag1"] = "value1" },
};

string json = JsonConvert.SerializeObject(original, WriteJsonSettings);
var deserialized = JsonConvert.DeserializeObject<HistoryEvent>(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<string,
// string> 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<JsonSerializationException>(
() => JsonConvert.DeserializeObject<HistoryEvent>(json, ReadJsonSettings));
}

[TestMethod]
public void RejectsNonAllowlistedNestedType()
{
// Embed a malicious $type inside an otherwise valid ExecutionStartedEvent's Tags
// member. The Tags member is IDictionary<string,string>, so the $type token is
// honored by Newtonsoft.Json when reading. The binder must reject any concrete
// type that is not assignable to IDictionary<string,string> 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<string,string>,
// 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<JsonSerializationException>(
() => JsonConvert.DeserializeObject<HistoryEvent>(json, ReadJsonSettings));
}

[TestMethod]
public void RejectsNonBclDictionaryAssembly()
{
// Even an IDictionary<string,string> 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<JsonSerializationException>(
() => JsonConvert.DeserializeObject<HistoryEvent>(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<JsonSerializationException>(
() => binder.BindToType(assemblyName: null, typeName: "DurableTask.Core.History.ExecutionStartedEvent"));
}

[TestMethod]
public void AllowsDictionaryStringStringForTags()
{
// The exact concrete type Newtonsoft.Json emits for IDictionary<string,string> Tags.
string bclAssemblyName = typeof(string).Assembly.GetName().Name;
string dictAssemblyName = typeof(Dictionary<string, string>).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<HistoryEvent>(json, ReadJsonSettings);
Assert.AreEqual("v1", deserialized.Tags["tag1"]);
}

[TestMethod]
public void AllowsSortedDictionaryStringStringForTags()
{
// Public DTFx APIs accept any IDictionary<string, string> 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<string, string>-shape post-check.
string bclAssemblyName = typeof(string).Assembly.GetName().Name;
string sortedDictAssemblyName = typeof(SortedDictionary<string, string>).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<HistoryEvent>(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.<X>' type name. PackageUpgradeSerializationBinder
// (the base class) rewrites these to 'DurableTask.Core.<X>' 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);
}
}
}
Loading