Skip to content
Draft
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,192 @@
// ----------------------------------------------------------------------------------
// 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.AzureServiceFabric.Tests
{
using System;
using System.Collections.Generic;
using DurableTask.AzureServiceFabric.Service;
using DurableTask.Core;
using DurableTask.Core.History;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;

[TestClass]
public class AllowedTypesSerializationBinderTests
{
readonly AllowedTypesSerializationBinder binder = new AllowedTypesSerializationBinder();

[TestMethod]
public void BindToType_AllowsDurableTaskCoreTypes()
{
var type = typeof(TaskMessage);
var result = this.binder.BindToType(type.Assembly.GetName().Name, type.FullName);
Assert.AreEqual(type, result);
}

[TestMethod]
public void BindToType_AllowsHistoryEventSubclasses()
{
var type = typeof(ExecutionStartedEvent);
var result = this.binder.BindToType(type.Assembly.GetName().Name, type.FullName);
Assert.AreEqual(type, result);
}

[TestMethod]
public void BindToType_AllowsServiceFabricTypes()
{
var type = typeof(FabricOrchestrationProvider);
var result = this.binder.BindToType(type.Assembly.GetName().Name, type.FullName);
Assert.AreEqual(type, result);
}

[TestMethod]
public void BindToType_AllowsMscorlibTypes()
{
var type = typeof(Dictionary<string, string>);
var result = this.binder.BindToType("mscorlib", type.FullName);
Assert.AreEqual(type, result);
}

[TestMethod]
public void BindToType_AllowsQualifiedAssemblyName()
{
var type = typeof(TaskMessage);
string qualifiedName = type.Assembly.FullName; // e.g. "DurableTask.Core, Version=..."
var result = this.binder.BindToType(qualifiedName, type.FullName);
Assert.AreEqual(type, result);
}

[TestMethod]
public void BindToType_AllowsNullAssemblyName()
{
// Null/empty assembly name should pass through to the default binder
var result = this.binder.BindToType(null, typeof(string).FullName);
Assert.IsNotNull(result);
}

[TestMethod]
public void BindToType_RejectsArbitraryAssembly()
{
Assert.ThrowsException<InvalidOperationException>(() =>
this.binder.BindToType("Evil.Assembly", "Evil.PwnedDescriptor"));
}

[TestMethod]
public void BindToType_RejectsQualifiedArbitraryAssembly()
{
Assert.ThrowsException<InvalidOperationException>(() =>
this.binder.BindToType("Evil.Assembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Evil.PwnedDescriptor"));
}

[TestMethod]
public void BindToType_RejectsSystemDiagnosticsProcess()
{
// A common gadget type — must be rejected
var type = typeof(System.Diagnostics.Process);
Assert.ThrowsException<InvalidOperationException>(() =>
this.binder.BindToType(type.Assembly.GetName().Name, type.FullName));
}

[TestMethod]
public void BindToName_DelegatesToDefaultBinder()
{
this.binder.BindToName(typeof(TaskMessage), out string assemblyName, out string typeName);
// DefaultSerializationBinder delegates to the runtime; just verify it doesn't throw
// and returns consistent results for a known type.
this.binder.BindToName(typeof(TaskMessage), out string assemblyName2, out string typeName2);
Assert.AreEqual(assemblyName, assemblyName2);
Assert.AreEqual(typeName, typeName2);
}

[TestMethod]
public void RoundTrip_TaskMessageWithHistoryEvent_Succeeds()
{
var message = new TaskMessage
{
SequenceNumber = 42,
OrchestrationInstance = new OrchestrationInstance { InstanceId = "test-1", ExecutionId = "exec-1" },
Event = new ExecutionStartedEvent(-1, "input-data")
{
Tags = new Dictionary<string, string> { { "key", "value" } },
Name = "TestOrchestration",
Version = "1.0"
}
};

var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All,
SerializationBinder = this.binder
};

string json = JsonConvert.SerializeObject(message, settings);
var deserialized = JsonConvert.DeserializeObject<TaskMessage>(json, settings);

Assert.IsNotNull(deserialized);
Assert.AreEqual(42, deserialized.SequenceNumber);
Assert.AreEqual("test-1", deserialized.OrchestrationInstance.InstanceId);
Assert.IsInstanceOfType(deserialized.Event, typeof(ExecutionStartedEvent));

var startedEvent = (ExecutionStartedEvent)deserialized.Event;
Assert.AreEqual("TestOrchestration", startedEvent.Name);
Assert.AreEqual("value", startedEvent.Tags["key"]);
}

[TestMethod]
public void Deserialize_MaliciousPayload_IsRejected()
{
string maliciousJson = @"{
""$type"": ""System.Diagnostics.Process, System"",
""StartInfo"": { ""FileName"": ""cmd.exe"" }
}";

var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All,
SerializationBinder = this.binder
};

// Newtonsoft wraps the binder's InvalidOperationException in a JsonSerializationException
var ex = Assert.ThrowsException<JsonSerializationException>(() =>
JsonConvert.DeserializeObject<object>(maliciousJson, settings));
Assert.IsInstanceOfType(ex.InnerException, typeof(InvalidOperationException));
StringAssert.Contains(ex.InnerException.Message, "is not allowed");
}

[TestMethod]
public void Settings_DefaultBinderIsAllowedTypes()
{
var providerSettings = new FabricOrchestrationProviderSettings();
Assert.IsNotNull(providerSettings.JsonSerializationBinder);
Assert.IsInstanceOfType(providerSettings.JsonSerializationBinder, typeof(AllowedTypesSerializationBinder));
}

[TestMethod]
public void Settings_BinderCanBeSetToNull()
{
var providerSettings = new FabricOrchestrationProviderSettings();
providerSettings.JsonSerializationBinder = null;
Assert.IsNull(providerSettings.JsonSerializationBinder);
}

[TestMethod]
public void Settings_BinderCanBeOverridden()
{
var customBinder = new Newtonsoft.Json.Serialization.DefaultSerializationBinder();
var providerSettings = new FabricOrchestrationProviderSettings();
providerSettings.JsonSerializationBinder = customBinder;
Assert.AreSame(customBinder, providerSettings.JsonSerializationBinder);
}
}
}
20 changes: 20 additions & 0 deletions docs/providers/service-fabric.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Service Fabric handles partitioning automatically based on your service configur
| `TaskActivityDispatcherSettings.MaxConcurrentActivities` | Max concurrent activities | 1000 |
| `TaskActivityDispatcherSettings.DispatcherCount` | Number of activity dispatchers | 10 |
| `LoggerFactory` | Optional logger factory for diagnostics | null |
| `JsonSerializationBinder` | `ISerializationBinder` that restricts which types can be deserialized from incoming JSON requests on the proxy endpoint | `AllowedTypesSerializationBinder` |

### Example Configuration

Expand All @@ -150,6 +151,25 @@ var settings = new FabricOrchestrationProviderSettings
};
```

### Serialization Security

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.

To provide a custom binder:

```csharp
settings.JsonSerializationBinder = new MyCustomSerializationBinder();
```

To disable type restrictions and restore legacy behavior:

```csharp
// ⚠️ Not recommended: disables deserialization type restrictions.
// Only use this if you have other security controls in place
// (e.g., network isolation, mutual TLS) to protect the proxy endpoint.
settings.JsonSerializationBinder = null;
```

## Client Access

### From Within Service Fabric
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@

namespace DurableTask.AzureServiceFabric
{
using DurableTask.AzureServiceFabric.Service;
using DurableTask.Core;
using DurableTask.Core.Settings;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Serialization;

/// <summary>
/// Provides settings for service fabric based custom provider implementations
Expand Down Expand Up @@ -56,5 +58,17 @@ public FabricOrchestrationProviderSettings()
/// Gets or sets the optional <see cref="ILoggerFactory"/> to use for diagnostic logging.
/// </summary>
public ILoggerFactory LoggerFactory { get; set; }

/// <summary>
/// Gets or sets the <see cref="ISerializationBinder"/> used to restrict which types can be
/// deserialized from incoming JSON requests on the proxy endpoint. This protects against
/// untrusted <c>$type</c> metadata in JSON payloads.
/// <para>
/// Defaults to <see cref="AllowedTypesSerializationBinder"/>, which permits only known
/// DurableTask and core system types. Set a custom <see cref="ISerializationBinder"/> to
/// override, or set to <c>null</c> to disable type restrictions (not recommended).
/// </para>
/// </summary>
public ISerializationBinder JsonSerializationBinder { get; set; } = new AllowedTypesSerializationBinder();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// ----------------------------------------------------------------------------------
// 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.AzureServiceFabric.Service
{
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json.Serialization;

/// <summary>
/// A serialization binder that restricts deserialization to only known DurableTask types
/// and core system types. This prevents untrusted <c>$type</c> metadata in JSON payloads
/// from loading arbitrary assemblies or instantiating arbitrary types.
/// </summary>
public sealed class AllowedTypesSerializationBinder : ISerializationBinder
{
static readonly HashSet<string> AllowedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
typeof(Core.TaskMessage).Assembly.GetName().Name, // DurableTask.Core
typeof(FabricOrchestrationProvider).Assembly.GetName().Name, // DurableTask.AzureServiceFabric
"mscorlib", // .NET Framework core types
"System.Private.CoreLib", // .NET Core/5+ core types
};
Comment on lines +30 to +36

readonly DefaultSerializationBinder defaultBinder = new DefaultSerializationBinder();
readonly ConcurrentDictionary<string, bool> assemblyAllowCache = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);

/// <inheritdoc />
public Type BindToType(string assemblyName, string typeName)
{
if (!IsAssemblyAllowed(assemblyName))
{
throw new InvalidOperationException(
$"Deserialization of type '{typeName}' from assembly '{assemblyName}' is not allowed. " +
$"Only known DurableTask and core system types are permitted.");
}

return this.defaultBinder.BindToType(assemblyName, typeName);
}

/// <inheritdoc />
public void BindToName(Type serializedType, out string assemblyName, out string typeName)
{
this.defaultBinder.BindToName(serializedType, out assemblyName, out typeName);
}

bool IsAssemblyAllowed(string assemblyName)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
// No assembly specified — let the default binder resolve it.
return true;
}

return this.assemblyAllowCache.GetOrAdd(assemblyName, name =>
{
// Strip version/culture/publicKeyToken if present (e.g., "mscorlib, Version=4.0.0.0, ...")
int commaIndex = name.IndexOf(',');
string shortName = commaIndex >= 0 ? name.Substring(0, commaIndex).Trim() : name.Trim();
return AllowedAssemblyNames.Contains(shortName);
});
}
}
}
10 changes: 9 additions & 1 deletion src/DurableTask.AzureServiceFabric/Service/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@ namespace DurableTask.AzureServiceFabric.Service
using DurableTask.Core;
using DurableTask.AzureServiceFabric;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Serialization;
using Owin;

class Startup : IOwinAppBuilder
{
FabricOrchestrationProvider fabricOrchestrationProvider;
ISerializationBinder serializationBinder;
string listeningAddress;

public Startup(string listeningAddress, FabricOrchestrationProvider fabricOrchestrationProvider)
public Startup(string listeningAddress, FabricOrchestrationProvider fabricOrchestrationProvider, ISerializationBinder serializationBinder)
{
this.listeningAddress = listeningAddress ?? throw new ArgumentNullException(nameof(listeningAddress));
this.fabricOrchestrationProvider = fabricOrchestrationProvider ?? throw new ArgumentNullException(nameof(fabricOrchestrationProvider));
this.serializationBinder = serializationBinder;
}

public string GetListeningAddress()
Expand Down Expand Up @@ -61,6 +64,11 @@ void IOwinAppBuilder.Startup(IAppBuilder appBuilder)
config.Formatters.Remove(config.Formatters.XmlFormatter);
config.Formatters.Remove(config.Formatters.FormUrlEncodedFormatter);
config.Formatters.JsonFormatter.SerializerSettings.TypeNameHandling = Newtonsoft.Json.TypeNameHandling.All;
if (this.serializationBinder != null)
{
config.Formatters.JsonFormatter.SerializerSettings.SerializationBinder = this.serializationBinder;
}

appBuilder.UseWebApi(config);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ public ServiceReplicaListener CreateServiceReplicaListener()
string protocol = this.enableHttps ? "https" : "http";
string listeningAddress = string.Format(CultureInfo.InvariantCulture, "{0}://{1}:{2}/{3}/dtfx/", protocol, ipAddress, serviceEndpoint.Port, context.PartitionId);

return new OwinCommunicationListener(new Startup(listeningAddress, this.fabricOrchestrationProvider));
return new OwinCommunicationListener(new Startup(listeningAddress, this.fabricOrchestrationProvider, this.fabricOrchestrationProviderSettings.JsonSerializationBinder));
}, Constants.TaskHubProxyServiceName);
}

Expand Down
Loading