dotnet / winforms

Windows Forms is a .NET UI framework for building Windows desktop applications.
MIT License
4.43k stars 986 forks source link

[API Proposal] New Clipboard and DataObject APIs #12362

Open Tanya-Solyanik opened 1 month ago

Tanya-Solyanik commented 1 month ago

Background and motivation

OLE clipboard supports standard exchange formats and additionally allows users to register custom formats for data exchange. Although standard exchange formats do not use binary format as they are defined by Windows, custom format serialization of objects in .NET has used the BinaryFormatter (in both WinForms and WPF). WinForms Clipboard falls back to the BinaryFormatter when consuming custom types stored as custom clipboard formats. We propose a set of Clipboard and DataObject APIs that make it easier to follow best practices when deserializing untrusted data. They restrict unbounded BinaryFormatter deserialization to known types and provide an alternative serialization method, JSON, for user types.

API Proposal

We propose the same API changes in WinForms and WPF assemblies. The new API surface will be backed by a shared implementation. The proposed APIs will support our updated Clipboard and Drag/Drop guidance which is:

Clipboard

namespace System.Windows.Forms;
and 
namespace System.Windows;

public static partial class Clipboard
{
+   // Saves the data onto the clipboard in the specified format using System.Text.Json. Will throw InvalidOperationException if DataObject is passed as it is ambiguous what user is intending given DataObject cannot be meaningfully JSON serialized.
+   public static void SetDataAsJson<T>(string format, T data) { }

+   [Obsolete("`Clipboard.GetData(string)` method is obsolete. Use `Clipboard.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")
+   [EditorBrowsable(EditorBrowsableState.Never)]
    public static object? GetData(string format) { }

+   // Verifies that payload contains type T and then attempts to read or deserialize it. 
+   public static bool TryGetData<T>(string format, out T data) { }
+   // Uses user-provided resolve to match the requested type to the payload content and to rehydrate the payload.
+   public static bool TryGetData<T>(string format, Func<Reflection.Metadata.TypeName, Type> resolver, out T data) { }
}

Managed implementation of OLE's IDataObject definition and is being used in all OLE operations for WinForms/WPF.

DataObject

namespace System.Windows.Forms;
and 
namespace System.Windows;

public partial class DataObject : IDataObject, Runtime.InteropServices.ComTypes.IDataObject
{
+   // Stores the specified data and its associated format in this instance using System.Text.Json. Will throw InvalidOperationException if DataObject is passed as it is ambiguous what user is intending given DataObject cannot be meaningfully JSON serialized.
+   public void SetDataAsJson<T>(T data) { }
+   public void SetDataAsJson<T>(string format, T data) { }
+   public void SetDataAsJson<T>(string format, bool autoConvert, T data) { }

+   [Obsolete("`DataObject.GetData` methods are obsolete. Use the corresponding `DataObject.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")]
+   [EditorBrowsable(EditorBrowsableState.Never)]
    public virtual object? GetData(string format, bool autoConvert) { }

+   [Obsolete("`DataObject.GetData` methods are obsolete. Use the corresponding `DataObject.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")]
+   [EditorBrowsable(EditorBrowsableState.Never)]
    public virtual object? GetData(string format) { }

+   [Obsolete("`DataObject.GetData` methods are obsolete. Use the corresponding `DataObject.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")]
+   [EditorBrowsable(EditorBrowsableState.Never)]
    public virtual object? GetData(Type format) { }

+   public virtual bool TryGetData<T>(out T data) { }
+   public virtual bool TryGetData<T>(string format, out T data) { }
+   public virtual bool TryGetData<T>(string format, bool autoConvert, out T data) { }
+   public virtual bool TryGetData<T>(string format, Func<Reflection.Metadata.TypeName, Type> resolver, bool autoConvert, out T data) { }
}

IDataObject

namespace System.Windows.Forms;
and 
namespace System.Windows;

public partial interface IDataObject
{
    object? GetData(string format, bool autoConvert);
    object? GetData(string format);
    object? GetData(Type format);

+   // Default implementations of these interface methods are using the existing GetData methods.
+   bool TryGetData<T>(out T data);
+   bool TryGetData<T>(string format, out T data);
+   bool TryGetData<T>(string format, bool autoConvert, out T data);
+   bool TryGetData<T>(string format, Func<Reflection.Metadata.TypeName, Type> resolver, bool autoConvert, out T data);
}

Control

namespace System.Windows.Forms;
public class partial class Control
{
+   // Begins drag operation, storing the drag data using System.Text.Json. Will throw InvalidOperationException if DataObject is passed to have a better error reporting in a common scenario.
+   public DragDropEffects DoDragDropAsJson<T>(T data, DragDropEffects allowedEffects, Bitmap? dragImage, Point cursorOffset, bool useDefaultDragImage)
+   public DragDropEffects DoDragDropAsJson<T>(T data, DragDropEffects allowedEffects)
}

DragDrop

namespace System.Windows;
public static class DragDrop
{
+    // Begins drag operation, storing the drag data using System.Text.Json. Will throw InvalidOperationException if DataObject is passed to have a better error reporting in a common scenario.
+    public static DragDropEffects DoDragDropAsJson<T>(DependencyObject dragSource, T data, DragDropEffects allowedEffects)
}

ClipboardProxy

// VisualBasic wrapper for WinForms Clipboard.
namespace Microsoft.VisualBasic.MyServices
public partial class ClipboardProxy
{
+   public void SetDataAsJson<T>(T data) { }
+   public void SetDataAsJson<T>(string format, T data) { }

+   [Obsolete("`ClipboardProxy.GetData(As String)` method is obsolete. Use `ClipboardProxy.TryGetData(Of T)` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")
+   [EditorBrowsable(EditorBrowsableState.Never)]
    public object GetData(string format) { } 

+   public bool TryGetData<T>(string format, out T data) { }
+   public bool TryGetData<T>(string format, System.Func<System.Reflection.Metadata.TypeName, System.Type> resolver, out T data) { }
}

New configuration switch: ClipboardDragDrop.EnableUnsafeBinaryFormatterSerialization - controls whether BinaryFormatter is enabled as a fallback for Clipboard and Drag/drop scenarios. By default, it is false.

API Usage

Clipboard API Examples

Before introduction of new APIs
  1. Users would need to enable BinaryFormatter to serialize/deserialize their types.
    
    [Serializable]
    public class WeatherForecastPOCO
    {
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
    }

public void SetGetClipboardData() { WeatherForecastPOCO weatherForecast = new() { Date = DateTime.Parse("2019-08-01"), TemperatureCelsius = 25, Summary = "Hot", };

// BinaryFormatter must be enabled for this to be successful.
Clipboard.SetData("myCustomFormat", weatherForecast);

pragma warning disable WFDEV005 // Type or member is obsolete

if (Clipboard.GetData("myCustomFormat") is WeatherForecast forecast)

pragma warning restore WFDEV005

{
    // Do things with forecast.
}

}


2. Users could use manual JSON serialization to avoid opting into the BinaryFormatter with the old APIs.
```c#
public class WeatherForecastPOCO
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

public void SetGetClipboardDataJson()
{
    WeatherForecastPOCO weatherForecast = new()
    {
        Date = DateTime.Parse("2019-08-01"),
        TemperatureCelsius = 25,
        Summary = "Hot",
    };
    byte[] serialized = JsonSerializer.SerializeToUtf8Bytes(weatherForecast);
    Clipboard.SetData("myCustomFormat", serialized);
#pragma warning disable WFDEV005 // Type or member is obsolete
    if (Clipboard.GetData("myCustomFormat") is byte[] byteData)
#pragma warning restore WFDEV005
    {
        if (JsonSerializer.Deserialize(byteData, typeof(WeatherForecast)) is WeatherForecast forecast)
        {
            // Do things with forecast.
        }
    }
}
After introduction of new APIs
  1. No need for users to manually JSON serialize their data and can specify directly in TryGetData what type they are expecting. This code does not require BinaryFormatter.
public class WeatherForecastPOCO
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
}

public void SetGetClipboardData()
{
    WeatherForecastPOCO weatherForecast = new()
    {
        Date = DateTime.Parse("2019-08-01"),
        TemperatureCelsius = 25,
        Summary = "Hot",
    };
    Clipboard.SetDataAsJson("myCustomFormat", weatherForecast);
    if (Clipboard.TryGetData("myCustomFormat", WeatherForecast? forecast))
    {
        // Do things with forecast.
    }
}
  1. When exchange types can't be replaced by POCO types, application can restrict BinaryFormatter deserialization by providing a set of allowed types.
    
    // Writer side
    using Font value = new("Microsoft Sans Serif", emSize: 10);
    Clipboard.SetData("font", value);

// Consumer side if (Clipboard.TryGetData("font", FontResolver, out Font data)) { // Do things with data }

private static Type FontResolver(TypeName typeName) { (string name, Type type)[] allowedTypes = [ (typeof(FontStyle).FullName!, typeof(FontStyle)), (typeof(FontFamily).FullName!, typeof(FontFamily)), (typeof(GraphicsUnit).FullName!, typeof(GraphicsUnit)), ];

string fullName = typeName.FullName;
foreach (var (name, type) in allowedTypes)
{
    // Namespace-qualified type name.
    if (name == fullName)
    {
        return type;
    }
}

throw new NotSupportedException($"Can't resolve {fullName}");

}


#### Drag/Drop Usage Example
No BinaryFormatter is required.

```c#
private Form _form1 = new();
private Control _beingDrag = new();
_beingDrag.MouseDown += beingDrag_MouseDown;
_form1.DragDrop += Form1_DragDrop;

void beingDrag_MouseDown(object sender, MouseEventArgs e)
{
    WeatherForecast weatherForecast = new WeatherForecast
    {
        Date = DateTimeOffset.Now,
        TemperatureCelsius = 25,
        Summary = "Hot"
    };

    _beingDrag.DoDragDropAsJson(weatherForecast, DragDropEffects.Copy);    
}

void Form1_DragDrop(object sender, DragEventArgs e)
{
    DataObject dataObject = e.Data;
    if (dataObject.TryGetData(typeof(WeatherForecast), out WeatherForecast? deserialized))
    {
        // Do things with deserialized data.
    }
}

Alternative Designs

  1. Replace a Func<TypeName, Type> with a resolver interface that can be reused in ResX and ActiveX scenarios and potentially in other Runtime scenarios, move the IResolver interface into System.Runtime.Serialization namespace. That would be a replacement for the resolver Func<TypeName, Type>.
bool TryGetData<T>(string format, IResolver resolver, bool autoConvert, out T data);

namespace System;
public interface IResolver
{
    Type TypeFromTypeName(TypeName typeName);
}
  1. Should we make the GetData methods in the managed IDataObject interface obsolete? These methods are implemented by the user, we don’t know if they are vulnerable or not. But they propagate a bad pattern by returning an unconstrained type (System.Object). There might be too many false positives if we obsolete them. The recommended way is for users to derive from the managed DataObject, not to implement the interface from scratch, and that scenario is covered.

  2. Should we make the SetData overloads obsolete? No, this scenario is not vulnerable, it propagates a bad pattern only when serializing more complex types. We will address this with an analyzer.

  3. Why take T for SetDataAsJson / DoDragDropAsJson APIs instead of object? We need to save the original type of the data that is being passed in so that we can rehydrate the type when the user asks for it, meaning that we will need to rely on Object.GetType to get the type of the passed in data in SetDataAsJson / DoDragDropAsJson APIs. This is not trim friendly because it might use reflection.

  4. Should an optional parameter to SetData and DoDragDrop APIs to indicate serializing with Json is desired instead of introducing a new API signature? This is an option, but it is again not trim friendly as we would need to rely on Object.GetType.

  5. Should we make the managed IDataObject interface obsolete? The shape of the managed interface matches that of the OLE interface, and the whole ecosystem depends on it, so any replacement would be very similar. We assume that most use cases override our DataObject instead of implementing the IDataObject from scratch.

  6. Should the consumption side APIs be T GetData<T>(..) or bool TryGetData<T>(… out T data). No strong preference, an API that returns a Boolean seems to be more convenient for the common use patterns observed in GitHUB.

  7. Naming for the configuration switch should indicate that it’s applicable to WPF as well when we share code with WPF. After the code is merged in the WPF repo, System.Windows.Forms.Clipboard and System.Windows.Clipboard will share the implementation. We want the configuration switch name to indicate that it's applicable to both. We could use the common namespace name portion: System.Windows.ClipboardDragDrop.EnableUnsafeBinaryFormatterSerialization Or System.ClipboardDragDrop.EnableUnsafeBinaryFormatterSerialization Or no namespace name ClipboardDragDrop.EnableUnsafeBinaryFormatterSerialization

Risks

• Users would have to implement the assembly loader and type resolver that works with TryGetData() to support types other than "T". If that resolver calls Type.GetType() they might lose control over assembly loading. Proposed APIs that do not accept the type resolver parameter, don't have this issue. When doing type matching, we rely on NrbfDecoder and type name matching API to be safe (threat model). Sample user-provided resolver code:

internal static Type MyResolver(TypeName typeName)
{
    Type[] allowedTypes =
    [
         typeof(MyClass),
         typeof(MyClass1)
    ];

    foreach (Type type in allowedTypes)
    {
        // Namespace-qualified type name.
        if (typeName.FullName == type.FullName!)
        {
            return type;
        }
    }

     // Do not call Type.GetType(typeName.AssemblyQualifiedName), throw for the unexpected types instead.
    throw new NotSupportedException();
}

• When deserializing objects that have been JSON serialized, this carries the same risks as any JSON data going through System.Text.Json, so it is possible to misuse SetDataAsJson to do bad things during clipboard and drag/drop operation (System.Text.Json threat model). As with any data, users need to trust the JSON data they are trying to grab.

Risk mitigation

We are adding a new configuration switch that would block the fallback into BinaryFormatter use in Clipboard and DragDrop scenarios, the proposed APIs allow users to use JSON format instead. We will encourage users to use only primitive exchange types or POCOc.

Will this feature affect UI controls?

No

Tanya-Solyanik commented 1 month ago

FYI @pchaurasia14 , @singhashish-wpf , @Kuldeep-MS

h3xds1nz commented 3 weeks ago

@miloush Look, new APIs!

miloush commented 3 weeks ago

Thanks @h3xds1nz, would have missed this.

The API changes pertaining to WPF should be posted separately in the WPF repo.

In general I don't have anything against adding new methods, although I don't understand why they wouldn't have the index parameter. If people are happy to do breaking changes to the IDataObject interface, it should include methods taking index for both old and new methods.

Is it suggested that the shared implementation would support WPF primitive types as much as the System.Drawing ones? Because the link to intrinsically handled types does not list any WPF types.

I am not thrilled about obsoleting methods that don't have any equivalent (people can put whole controls in the DataObject now and it works even when not serializable, and you can test for reference equality), but hiding them completely is nefarious.

bartonjs commented 3 weeks ago
namespace System.Windows.Forms;
and 
namespace System.Windows;

public static partial class Clipboard
{
+   public static void SetDataAsJson<T>(string format, T data) { }

+   [Obsolete("`Clipboard.GetData(string)` method is obsolete. Use `Clipboard.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")
    public static object? GetData(string format) { }

+   public static bool TryGetData<T>(string format, out T data) { }
+   public static bool TryGetData<T>(string format, Func<Reflection.Metadata.TypeName, Type> resolver, out T data) { }
}

public partial class DataObject : IDataObject, Runtime.InteropServices.ComTypes.IDataObject
+                                 ITypedDataObject
{
+   public void SetDataAsJson<T>(T data) { }
+   public void SetDataAsJson<T>(string format, T data) { }
+   public void SetDataAsJson<T>(string format, bool autoConvert, T data) { }

+   [Obsolete("`DataObject.GetData` methods are obsolete. Use the corresponding `DataObject.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")]
    public virtual object? GetData(string format, bool autoConvert) { }

+   [Obsolete("`DataObject.GetData` methods are obsolete. Use the corresponding `DataObject.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")]
    public virtual object? GetData(string format) { }

+   [Obsolete("`DataObject.GetData` methods are obsolete. Use the corresponding `DataObject.TryGetData<T>` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")]
    public virtual object? GetData(Type format) { }

+   public bool TryGetData<T>(out T data) { }
+   public bool TryGetData<T>(string format, out T data) { }
+   public bool TryGetData<T>(string format, bool autoConvert, out T data) { }
+   public bool TryGetData<T>(string format, Func<Reflection.Metadata.TypeName, Type> resolver, bool autoConvert, out T data) { }
+   protected virtual bool TryGetDataCore<T>(string format, Func<Reflection.Metadata.TypeName, Type> resolver, bool autoConvert, out T data) { }
}

+public interface ITypedDataObject
+{
+   bool TryGetData<T>(out T data);
+   bool TryGetData<T>(string format, out T data);
+   bool TryGetData<T>(string format, bool autoConvert, out T data);
+   bool TryGetData<T>(string format, Func<Reflection.Metadata.TypeName, Type> resolver, bool autoConvert, out T data);
+}

+public sealed class DataObjectExtensions
{
+   public static bool TryGetData<T>(this IDataObject dataObject, out T data);
+   public static bool TryGetData<T>(this IDataObject dataObject, string format, out T data);
+   public static bool TryGetData<T>(this IDataObject dataObject, string format, bool autoConvert, out T data);
+   public static bool TryGetData<T>(this IDataObject dataObject, string format, Func<Reflection.Metadata.TypeName, Type> resolver, bool autoConvert, out T data);
}
namespace System.Windows.Forms;
public class partial class Control
{
+   public DragDropEffects DoDragDropAsJson<T>(T data, DragDropEffects allowedEffects, Bitmap? dragImage, Point cursorOffset, bool useDefaultDragImage)
+   public DragDropEffects DoDragDropAsJson<T>(T data, DragDropEffects allowedEffects)
}
namespace System.Windows;
public static class DragDrop
{
+    public static DragDropEffects DoDragDropAsJson<T>(DependencyObject dragSource, T data, DragDropEffects allowedEffects)
}
// VisualBasic wrapper for WinForms Clipboard.
namespace Microsoft.VisualBasic.MyServices
public partial class ClipboardProxy
{
+   public void SetDataAsJson<T>(T data) { }
+   public void SetDataAsJson<T>(string format, T data) { }

+   [Obsolete("`ClipboardProxy.GetData(As String)` method is obsolete. Use `ClipboardProxy.TryGetData(Of T)` instead.", false, DiagnosticId = "WFDEV005", UrlFormat = "https://aka.ms/winforms-warnings/{0}")
    public object GetData(string format) { } 

+   public bool TryGetData<T>(string format, out T data) { }
+   public bool TryGetData<T>(string format, System.Func<System.Reflection.Metadata.TypeName, System.Type> resolver, out T data) { }
}
miloush commented 3 weeks ago

For the index parameter, we have an API proposal in WPF affecting data objects: https://github.com/dotnet/wpf/issues/7744

It might be useful to do this as a part of unifying the codebase.

eerhardt commented 3 weeks ago

public partial class DataObject : IDataObject, Runtime.InteropServices.ComTypes.IDataObject ITypedDataObject { public void SetDataAsJson(T data) { }

Do we need overloads of these "Json" APIs that take a JsonTypeInfo, so the Json source generator can be used (to support trimming and native AOT)?

bartonjs commented 3 weeks ago

Do we need overloads of these "Json" APIs that take a JsonTypeInfo, so the Json source generator can be used (to support trimming and native AOT)?

That's the first bullet in the meeting summary. The answer was "no, not at this time"

miloush commented 3 weeks ago

Not to be too daring, but if it used XML, we wouldn't need the extra System.Text.Json dependency, especially if this is planned to be used in .NET Framework for interopability.

In general, the fact that it uses JSON seems to be a rather implementation detail of safety, especially if we don't allow reading and writing the JSON directly. Do I understand the idea correctly that the SetDataAsJson would still store the value as binary if it is one of the types on a safelist? If so, calling it AsJson seems a bit misleading.

MichaeIDietrich commented 3 weeks ago

My 2 Cents here. Just out of curiosity, why are the methods called SomethingAsJson, when they do not have any JSON-specific info in their signature? Isn't JSON here something like an implementation detail, like binary formatting is for the existing implementation?

SetDataAsJson feels more like a convenience method that should be provided as an extension method instead of being part of the public interface of the Clipboard class. But I guess that's not an option, since the class is static?

miloush commented 3 weeks ago

@MichaeIDietrich one option to do that would be to have a separate JsonClipboard or SafeClipboard or similar that could be an in-place replacement and only deal with the safe types. People have to opt in using the new API anyway, and you get freedom to design the API the way you want.