dotnet / winforms

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

[API Proposal] New Clipboard and DataObject APIs #12362

Open Tanya-Solyanik opened 2 days ago

Tanya-Solyanik commented 2 days 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 safer set of Clipboard and DataObject APIs that restrict unbounded BinaryFormatter deserialization to types known at the compile time and provide an alternative serialization method, JSON, for user types.

API Proposal

We propose the same API changes in WinForms and WPF assemblies. 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.

Will this feature affect UI controls?

No

Tanya-Solyanik commented 16 hours ago

FYI @pchaurasia14