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
@@ -0,0 +1,87 @@
// ----------------------------------------------------------------------------------
// 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.Emulator.Tests
{
using System;
using System.Collections.Generic;
using DurableTask.Core;
using DurableTask.Core.History;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;

[TestClass]
public class HistoryEventSerializationBinderTests
{
// Mirrors the production state settings used by LocalOrchestrationService.
static readonly JsonSerializerSettings StateJsonSettings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto,
SerializationBinder = new HistoryEventSerializationBinder()
};
Comment on lines +23 to +31
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file is under the legacy Test/ directory, but the solution and active test projects reference test/DurableTask.Emulator.Tests (lowercase). As a result, these tests likely won’t be compiled or executed in CI. Move the test into test/DurableTask.Emulator.Tests/ (or ensure the referenced test/DurableTask.Emulator.Tests.csproj includes it).

Copilot uses AI. Check for mistakes.

[TestMethod]
public void RoundTripsHistoryEventListWithPolymorphicEventsAndTags()
{
IList<HistoryEvent> original = new List<HistoryEvent>
{
new ExecutionStartedEvent(eventId: -1, input: "input")
{
Name = "Orchestration",
Version = "1.0",
OrchestrationInstance = new OrchestrationInstance
{
InstanceId = "instance-1",
ExecutionId = "execution-1"
},
Tags = new Dictionary<string, string> { ["tag1"] = "value1" }
},
new TaskScheduledEvent(eventId: 1)
{
Name = "Activity",
Version = "1.0",
Input = "activity-input"
}
};

string json = JsonConvert.SerializeObject(original, StateJsonSettings);
var deserialized = JsonConvert.DeserializeObject<IList<HistoryEvent>>(json, StateJsonSettings);

Assert.AreEqual(2, deserialized.Count);
Assert.IsInstanceOfType(deserialized[0], typeof(ExecutionStartedEvent));
var startedEvent = (ExecutionStartedEvent)deserialized[0];
Assert.AreEqual("Orchestration", startedEvent.Name);
Assert.AreEqual("input", startedEvent.Input);
Assert.AreEqual("value1", startedEvent.Tags["tag1"]);
Assert.IsInstanceOfType(deserialized[1], typeof(TaskScheduledEvent));
Assert.AreEqual("Activity", ((TaskScheduledEvent)deserialized[1]).Name);
}

[TestMethod]
public void RejectsNonAllowlistedRootType()
{
string json = "{\"$type\":\"System.IO.FileInfo, System.Private.CoreLib\",\"OriginalPath\":\"c:\\\\evil\"}";
Assert.ThrowsException<JsonSerializationException>(
() => JsonConvert.DeserializeObject<HistoryEvent>(json, StateJsonSettings));
}

[TestMethod]
public void RejectsNonAllowlistedNestedType()
{
string json = "[{\"$type\":\"DurableTask.Core.History.ExecutionStartedEvent, DurableTask.Core\","
+ "\"Tags\":{\"$type\":\"System.Collections.Generic.SortedDictionary`2[[System.String, System.Private.CoreLib],[System.String, System.Private.CoreLib]], System.Collections\"}}]";
Assert.ThrowsException<JsonSerializationException>(
() => JsonConvert.DeserializeObject<IList<HistoryEvent>>(json, StateJsonSettings));
}
}
}
59 changes: 59 additions & 0 deletions src/DurableTask.Emulator/HistoryEventSerializationBinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// ----------------------------------------------------------------------------------
// 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.Emulator
{
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 by the in-memory
/// emulator to round-trip the <see cref="IList{T}"/> of <see cref="HistoryEvent"/> that
/// represents an orchestration's runtime state. Only types defined in <c>DurableTask.Core</c>,
/// plus <see cref="Dictionary{TKey, TValue}"/> of strings, are accepted; any other <c>$type</c>
/// token is rejected with a <see cref="JsonSerializationException"/>.
/// </summary>
public sealed class HistoryEventSerializationBinder : PackageUpgradeSerializationBinder
{
static readonly Assembly DurableTaskCoreAssembly = typeof(HistoryEvent).Assembly;

/// <inheritdoc />
public override Type BindToType(string assemblyName, string typeName)
{
Type resolved = base.BindToType(assemblyName, typeName);

if (resolved == null || !IsAllowed(resolved))
{
throw new JsonSerializationException(
$"Type '{typeName}' from assembly '{assemblyName}' is not permitted by the orchestration runtime state serialization binder.");
}

return resolved;
}

static bool IsAllowed(Type type)
{
// Allow types defined in DurableTask.Core (HistoryEvent subclasses, OrchestrationInstance,
// ParentInstance, FailureDetails, etc.), plus Dictionary<string, string> for the
// IDictionary<string, string> Tags members declared on ExecutionStartedEvent /
// SubOrchestrationInstanceCreatedEvent / TaskScheduledEvent (Newtonsoft.Json emits a
// $type for these because the static declared type is an interface).
return type.Assembly == DurableTaskCoreAssembly
|| type == typeof(Dictionary<string, string>);
Comment on lines +51 to +56
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowlist in IsAllowed doesn’t include the common collection types that Json.NET can emit $type metadata for when TypeNameHandling.Auto is enabled (notably List<HistoryEvent> / IList<HistoryEvent> root values can be serialized with $type + $values). This will cause legitimate emulator state round-trips to fail with JsonSerializationException. Expand the allowlist to include the required collection types (ideally in a constrained way, e.g., only List<>/Dictionary<,> where the generic arguments are themselves allowed), or change the serializer usage to avoid emitting $type for the root collection.

Suggested change
// ParentInstance, FailureDetails, etc.), plus Dictionary<string, string> for the
// IDictionary<string, string> Tags members declared on ExecutionStartedEvent /
// SubOrchestrationInstanceCreatedEvent / TaskScheduledEvent (Newtonsoft.Json emits a
// $type for these because the static declared type is an interface).
return type.Assembly == DurableTaskCoreAssembly
|| type == typeof(Dictionary<string, string>);
// ParentInstance, FailureDetails, etc.), plus the constrained collection shapes that
// Json.NET may emit as $type metadata when TypeNameHandling.Auto is enabled:
// * List<T> where T is itself allowed (for IList<HistoryEvent> runtime state)
// * Dictionary<TKey, TValue> where both generic arguments are allowed
// String is permitted as a terminal type so Dictionary<string, string> continues to
// round-trip for the IDictionary<string, string> Tags members declared on
// ExecutionStartedEvent / SubOrchestrationInstanceCreatedEvent / TaskScheduledEvent.
if (type.Assembly == DurableTaskCoreAssembly || type == typeof(string))
{
return true;
}
if (type.IsGenericType)
{
Type genericTypeDefinition = type.GetGenericTypeDefinition();
Type[] genericArguments = type.GetGenericArguments();
if (genericTypeDefinition == typeof(List<>))
{
return genericArguments.Length == 1 && IsAllowed(genericArguments[0]);
}
if (genericTypeDefinition == typeof(Dictionary<,>))
{
return genericArguments.Length == 2
&& IsAllowed(genericArguments[0])
&& IsAllowed(genericArguments[1]);
}
}
return false;

Copilot uses AI. Check for mistakes.
}
}
}
6 changes: 5 additions & 1 deletion src/DurableTask.Emulator/LocalOrchestrationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ public class LocalOrchestrationService : IOrchestrationService, IOrchestrationSe
readonly object timerLock = new object();

readonly ConcurrentDictionary<string, TaskCompletionSource<OrchestrationState>> orchestrationWaiters;
static readonly JsonSerializerSettings StateJsonSettings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
static readonly JsonSerializerSettings StateJsonSettings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto,
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeNameHandling.Auto can cause Json.NET to emit a $type for the root runtime-state event collection (e.g., List<HistoryEvent> when serializing runtimeState.Events). With the new strict binder allowlist, that collection type may be rejected during deserialization, breaking emulator persistence/rehydration. Consider either (a) adjusting serialization to avoid writing a $type for the root collection (e.g., serialize/deserialize with an explicit declared type), or (b) expanding the allowlist to permit the specific collection types you actually emit (e.g., List<HistoryEvent>).

Suggested change
TypeNameHandling = TypeNameHandling.Auto,
TypeNameHandling = TypeNameHandling.Objects,

Copilot uses AI. Check for mistakes.
SerializationBinder = new HistoryEventSerializationBinder()
};

/// <summary>
/// Creates a new instance of the LocalOrchestrationService with default settings
Expand Down
Loading