Skip to content

Commit 47e73c8

Browse files
authored
Add a serialization binder for Service Fabric provider proxy (#1363)
* Add a serialization binder for Service Fabric provider * Enhance AllowedTypesSerializationBinder to restrict deserialization to specific Service Fabric proxy types and improve type validation logic * Address Copilot's comments * Add test for unresolvable type in AllowedTypesSerializationBinder and improve error message
1 parent 990a4c9 commit 47e73c8

6 files changed

Lines changed: 445 additions & 2 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// ----------------------------------------------------------------------------------
2+
// Copyright Microsoft Corporation
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
// Unless required by applicable law or agreed to in writing, software
8+
// distributed under the License is distributed on an "AS IS" BASIS,
9+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
// See the License for the specific language governing permissions and
11+
// limitations under the License.
12+
// ----------------------------------------------------------------------------------
13+
14+
namespace DurableTask.AzureServiceFabric.Tests
15+
{
16+
using System;
17+
using System.Collections.Generic;
18+
using System.Linq;
19+
using System.Reflection;
20+
using DurableTask.AzureServiceFabric.Models;
21+
using DurableTask.AzureServiceFabric.Service;
22+
using DurableTask.Core;
23+
using DurableTask.Core.History;
24+
using Microsoft.VisualStudio.TestTools.UnitTesting;
25+
using Newtonsoft.Json;
26+
27+
[TestClass]
28+
public class AllowedTypesSerializationBinderTests
29+
{
30+
readonly AllowedTypesSerializationBinder binder = new AllowedTypesSerializationBinder();
31+
32+
[TestMethod]
33+
public void BindToType_AllowsDurableTaskCoreTypes()
34+
{
35+
var type = typeof(TaskMessage);
36+
var result = this.binder.BindToType(type.Assembly.GetName().Name, type.FullName);
37+
Assert.AreEqual(type, result);
38+
}
39+
40+
[TestMethod]
41+
public void BindToType_AllowsHistoryEventSubclasses()
42+
{
43+
var type = typeof(ExecutionStartedEvent);
44+
var result = this.binder.BindToType(type.Assembly.GetName().Name, type.FullName);
45+
Assert.AreEqual(type, result);
46+
}
47+
48+
[TestMethod]
49+
public void BindToType_AllowsServiceFabricProxyTypes()
50+
{
51+
var type = typeof(CreateTaskOrchestrationParameters);
52+
var result = this.binder.BindToType(type.Assembly.GetName().Name, type.FullName);
53+
Assert.AreEqual(type, result);
54+
}
55+
56+
[TestMethod]
57+
public void BindToType_AllowsMscorlibTypes()
58+
{
59+
var type = typeof(Dictionary<string, string>);
60+
var result = this.binder.BindToType("mscorlib", type.FullName);
61+
Assert.AreEqual(type, result);
62+
}
63+
64+
[TestMethod]
65+
public void BindToType_AllowsQualifiedAssemblyName()
66+
{
67+
var type = typeof(TaskMessage);
68+
string qualifiedName = type.Assembly.FullName; // e.g. "DurableTask.Core, Version=..."
69+
var result = this.binder.BindToType(qualifiedName, type.FullName);
70+
Assert.AreEqual(type, result);
71+
}
72+
73+
[TestMethod]
74+
public void BindToType_AllowsNullAssemblyName()
75+
{
76+
// Null/empty assembly name should pass through to the default binder
77+
var result = this.binder.BindToType(null, typeof(string).FullName);
78+
Assert.IsNotNull(result);
79+
}
80+
81+
[TestMethod]
82+
public void BindToType_RejectsArbitraryAssembly()
83+
{
84+
Assert.ThrowsException<InvalidOperationException>(() =>
85+
this.binder.BindToType("Evil.Assembly", "Evil.PwnedDescriptor"));
86+
}
87+
88+
[TestMethod]
89+
public void BindToType_RejectsQualifiedArbitraryAssembly()
90+
{
91+
Assert.ThrowsException<InvalidOperationException>(() =>
92+
this.binder.BindToType("Evil.Assembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Evil.PwnedDescriptor"));
93+
}
94+
95+
[TestMethod]
96+
public void BindToType_RejectsSystemDiagnosticsProcess()
97+
{
98+
// A common gadget type — must be rejected
99+
var type = typeof(System.Diagnostics.Process);
100+
Assert.ThrowsException<InvalidOperationException>(() =>
101+
this.binder.BindToType(type.Assembly.GetName().Name, type.FullName));
102+
}
103+
104+
[TestMethod]
105+
public void BindToType_RejectsNonAllowlistedMscorlibType()
106+
{
107+
// System.Type is from mscorlib but not in the type allowlist
108+
Assert.ThrowsException<InvalidOperationException>(() =>
109+
this.binder.BindToType("mscorlib", typeof(Type).FullName));
110+
}
111+
112+
[TestMethod]
113+
public void BindToType_RejectsUnresolvableType()
114+
{
115+
// A type name that cannot be resolved should throw a controlled exception, not NullReferenceException
116+
var ex = Assert.ThrowsException<JsonSerializationException>(() =>
117+
this.binder.BindToType("DurableTask.Core", "DurableTask.Core.NonExistentType"));
118+
StringAssert.Contains(ex.Message, "NonExistentType");
119+
}
120+
121+
[TestMethod]
122+
public void BindToType_RejectsNonAllowlistedDurableTaskCoreType()
123+
{
124+
// TaskOrchestration is a DurableTask.Core type but not in the proxy endpoint allowlist
125+
var type = typeof(TaskOrchestration);
126+
Assert.ThrowsException<InvalidOperationException>(() =>
127+
this.binder.BindToType(type.Assembly.GetName().Name, type.FullName));
128+
}
129+
130+
[TestMethod]
131+
public void BindToType_AllowsAllHistoryEventKnownTypes()
132+
{
133+
IEnumerable<Type> knownTypes;
134+
try
135+
{
136+
knownTypes = HistoryEvent.KnownTypes();
137+
}
138+
catch (ReflectionTypeLoadException ex)
139+
{
140+
// In test environments, not all types may be loadable
141+
knownTypes = ex.Types.Where(t => t != null && !t.IsAbstract && typeof(HistoryEvent).IsAssignableFrom(t));
142+
}
143+
144+
foreach (Type knownType in knownTypes)
145+
{
146+
var result = this.binder.BindToType(knownType.Assembly.GetName().Name, knownType.FullName);
147+
Assert.AreEqual(knownType, result, $"HistoryEvent subclass {knownType.Name} should be allowed");
148+
}
149+
}
150+
151+
[TestMethod]
152+
public void BindToName_DelegatesToDefaultBinder()
153+
{
154+
this.binder.BindToName(typeof(TaskMessage), out string assemblyName, out string typeName);
155+
// DefaultSerializationBinder delegates to the runtime; just verify it doesn't throw
156+
// and returns consistent results for a known type.
157+
this.binder.BindToName(typeof(TaskMessage), out string assemblyName2, out string typeName2);
158+
Assert.AreEqual(assemblyName, assemblyName2);
159+
Assert.AreEqual(typeName, typeName2);
160+
}
161+
162+
[TestMethod]
163+
public void RoundTrip_TaskMessageWithHistoryEvent_Succeeds()
164+
{
165+
var message = new TaskMessage
166+
{
167+
SequenceNumber = 42,
168+
OrchestrationInstance = new OrchestrationInstance { InstanceId = "test-1", ExecutionId = "exec-1" },
169+
Event = new ExecutionStartedEvent(-1, "input-data")
170+
{
171+
Tags = new Dictionary<string, string> { { "key", "value" } },
172+
Name = "TestOrchestration",
173+
Version = "1.0"
174+
}
175+
};
176+
177+
var settings = new JsonSerializerSettings
178+
{
179+
TypeNameHandling = TypeNameHandling.All,
180+
SerializationBinder = this.binder
181+
};
182+
183+
string json = JsonConvert.SerializeObject(message, settings);
184+
var deserialized = JsonConvert.DeserializeObject<TaskMessage>(json, settings);
185+
186+
Assert.IsNotNull(deserialized);
187+
Assert.AreEqual(42, deserialized.SequenceNumber);
188+
Assert.AreEqual("test-1", deserialized.OrchestrationInstance.InstanceId);
189+
Assert.IsInstanceOfType(deserialized.Event, typeof(ExecutionStartedEvent));
190+
191+
var startedEvent = (ExecutionStartedEvent)deserialized.Event;
192+
Assert.AreEqual("TestOrchestration", startedEvent.Name);
193+
Assert.AreEqual("value", startedEvent.Tags["key"]);
194+
}
195+
196+
[TestMethod]
197+
public void Deserialize_MaliciousPayload_IsRejected()
198+
{
199+
string maliciousJson = @"{
200+
""$type"": ""System.Diagnostics.Process, System"",
201+
""StartInfo"": { ""FileName"": ""cmd.exe"" }
202+
}";
203+
204+
var settings = new JsonSerializerSettings
205+
{
206+
TypeNameHandling = TypeNameHandling.All,
207+
SerializationBinder = this.binder
208+
};
209+
210+
// Newtonsoft wraps the binder's InvalidOperationException in a JsonSerializationException
211+
var ex = Assert.ThrowsException<JsonSerializationException>(() =>
212+
JsonConvert.DeserializeObject<object>(maliciousJson, settings));
213+
Assert.IsInstanceOfType(ex.InnerException, typeof(InvalidOperationException));
214+
StringAssert.Contains(ex.InnerException.Message, "is not allowed");
215+
}
216+
217+
[TestMethod]
218+
public void Settings_DefaultBinderIsAllowedTypes()
219+
{
220+
var providerSettings = new FabricOrchestrationProviderSettings();
221+
Assert.IsNotNull(providerSettings.JsonSerializationBinder);
222+
Assert.IsInstanceOfType(providerSettings.JsonSerializationBinder, typeof(AllowedTypesSerializationBinder));
223+
}
224+
225+
[TestMethod]
226+
public void Settings_BinderCanBeSetToNull()
227+
{
228+
var providerSettings = new FabricOrchestrationProviderSettings();
229+
providerSettings.JsonSerializationBinder = null;
230+
Assert.IsNull(providerSettings.JsonSerializationBinder);
231+
}
232+
233+
[TestMethod]
234+
public void Settings_BinderCanBeOverridden()
235+
{
236+
var customBinder = new Newtonsoft.Json.Serialization.DefaultSerializationBinder();
237+
var providerSettings = new FabricOrchestrationProviderSettings();
238+
providerSettings.JsonSerializationBinder = customBinder;
239+
Assert.AreSame(customBinder, providerSettings.JsonSerializationBinder);
240+
}
241+
}
242+
}

docs/providers/service-fabric.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Service Fabric handles partitioning automatically based on your service configur
131131
| `TaskActivityDispatcherSettings.MaxConcurrentActivities` | Max concurrent activities | 1000 |
132132
| `TaskActivityDispatcherSettings.DispatcherCount` | Number of activity dispatchers | 10 |
133133
| `LoggerFactory` | Optional logger factory for diagnostics | null |
134+
| `JsonSerializationBinder` | `ISerializationBinder` that restricts which types can be deserialized from incoming JSON requests on the proxy endpoint | `AllowedTypesSerializationBinder` |
134135

135136
### Example Configuration
136137

@@ -150,6 +151,25 @@ var settings = new FabricOrchestrationProviderSettings
150151
};
151152
```
152153

154+
### Serialization Security
155+
156+
The proxy endpoint uses `TypeNameHandling.All` for JSON deserialization to support polymorphic types like `HistoryEvent`. By default, an `AllowedTypesSerializationBinder` restricts deserialization to types from `DurableTask.Core`, `DurableTask.AzureServiceFabric`, and core system assemblies. This prevents untrusted `$type` metadata in JSON payloads from loading arbitrary types.
157+
158+
To provide a custom binder:
159+
160+
```csharp
161+
settings.JsonSerializationBinder = new MyCustomSerializationBinder();
162+
```
163+
164+
To disable type restrictions and restore legacy behavior:
165+
166+
```csharp
167+
// ⚠️ Not recommended: disables deserialization type restrictions.
168+
// Only use this if you have other security controls in place
169+
// (e.g., network isolation, mutual TLS) to protect the proxy endpoint.
170+
settings.JsonSerializationBinder = null;
171+
```
172+
153173
## Client Access
154174

155175
### From Within Service Fabric

src/DurableTask.AzureServiceFabric/FabricOrchestrationProviderSettings.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313

1414
namespace DurableTask.AzureServiceFabric
1515
{
16+
using DurableTask.AzureServiceFabric.Service;
1617
using DurableTask.Core;
1718
using DurableTask.Core.Settings;
1819
using Microsoft.Extensions.Logging;
20+
using Newtonsoft.Json.Serialization;
1921

2022
/// <summary>
2123
/// Provides settings for service fabric based custom provider implementations
@@ -56,5 +58,17 @@ public FabricOrchestrationProviderSettings()
5658
/// Gets or sets the optional <see cref="ILoggerFactory"/> to use for diagnostic logging.
5759
/// </summary>
5860
public ILoggerFactory LoggerFactory { get; set; }
61+
62+
/// <summary>
63+
/// Gets or sets the <see cref="ISerializationBinder"/> used to restrict which types can be
64+
/// deserialized from incoming JSON requests on the proxy endpoint. This protects against
65+
/// untrusted <c>$type</c> metadata in JSON payloads.
66+
/// <para>
67+
/// Defaults to <see cref="AllowedTypesSerializationBinder"/>, which permits only known
68+
/// DurableTask and core system types. Set a custom <see cref="ISerializationBinder"/> to
69+
/// override, or set to <c>null</c> to disable type restrictions (not recommended).
70+
/// </para>
71+
/// </summary>
72+
public ISerializationBinder JsonSerializationBinder { get; set; } = new AllowedTypesSerializationBinder();
5973
}
6074
}

0 commit comments

Comments
 (0)