Skip to content
72 changes: 72 additions & 0 deletions .github/instructions/features.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,78 @@ AppContext switches allow runtime behavior changes without modifying connection
| `Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2` | `false` | Enables the new `ChannelDbConnectionPool` implementation |
| `Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows` | `false` | Forces managed SNI on Windows (instead of native SNI) |
| `Switch.Microsoft.Data.SqlClient.UseOneSecFloorInTimeoutCalculationDuringLogin` | `false` | Sets 1-second minimum in login timeout calculations |
| `Switch.Microsoft.Data.SqlClient.UseLegacyUdtAssemblyLoad` | `false` | Restores the pre-policy behavior of loading any assembly named by a server-supplied UDT assembly-qualified name, and of skipping the `[SqlUserDefinedType]` check |

### UDT Assembly Load Policy

A server-supplied UDT assembly-qualified name reaches `Assembly.Load`, so the
driver applies a deny-by-default policy before handing the name to the loader.
There is a single enforcing behavior, which permits:

| Permitted | Notes |
|-----------|-------|
| `Microsoft.SqlServer.Types` | Identity pinned: the version is normalized to the connection's negotiated type system version, and the public key token to the one Microsoft signs with |
| Assemblies on the allow list | The application explicitly naming what it is willing to have loaded |
| Assemblies already loaded into the process | Resolved to the instance the process already holds; the server-supplied version and public key token are discarded |

Everything else is refused. In particular, an assembly that is only *statically
referenced* by a loaded assembly is **not** permitted, because loading it is a
genuinely new load — precisely what this policy keeps under the application's
control rather than the server's.

Setting `UseLegacyUdtAssemblyLoad` disables the policy entirely and restores the
pre-policy behavior. It is a temporary compatibility escape hatch, not a
supported configuration.

Applications that use custom UDTs whose assemblies are loaded on demand must name
them explicitly through the `Microsoft.Data.SqlClient.UdtAssemblyAllowList`
AppContext data element, a semicolon-separated list of assembly names:

```csharp
AppDomain.CurrentDomain.SetData(
"Microsoft.Data.SqlClient.UdtAssemblyAllowList",
"Contoso.Udts;Fabrikam.Udts, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a");
```

Each entry is matched only on the components it specifies, so a simple name
permits any version, culture, and public key token, while a fully-qualified name
must match exactly.

Independently of the assembly policy, a resolved type that is not annotated with
`SqlUserDefinedTypeAttribute` is rejected before any of its code runs (except
under `UseLegacyUdtAssemblyLoad`). This is the gate that actually prevents
foreign code execution: on CoreCLR, neither `Assembly.Load`, nor resolving a type
from the assembly, nor reading that type's custom attributes runs anything from
it — a module initializer or static constructor runs on first real member access,
which is what `GetUdtValue` would otherwise perform.
Comment on lines +300 to +303

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If foreign code execution is the primary concern then GetCustomAttributes will run the attributes' constructors and module initializers. CustomAttributeData may be more relevant for our use case.


#### Compatibility impact

This policy is a behavior change for applications that use **custom** UDTs. The
built-in spatial types (`SqlGeography`, `SqlGeometry`, `SqlHierarchyId`) are
unaffected, since `Microsoft.SqlServer.Types` is permitted by identity.

An application is affected when the custom UDT's assembly is not yet loaded at
the moment the value is read. That is common whenever the *driver* materializes
the value and the application never names the type in its own code — generic data
access layers, micro-ORMs, `DataTable.Load`, and schema discovery. In those cases
the driver's own `Assembly.Load` was previously the thing that pulled the
assembly in, and it is now refused.

The symptom depends on the API:

| API | Symptom |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Flagging a few other scenarios.

  • SqlCommandBuilder
  • SqlBulkCopy
    • Between UDT columns in tables via SqlDataReader
    • From a SqlDataReader to a varbinary(max)
    • From a DataTable to a UDT column

I think most of them would be permitted: they either involve us transmitting UDTs, or us transferring them as a byte array without interpretation.

|-----|---------|
| `reader[i]`, `GetValue`, UDT output parameters | `SqlException` naming the assembly and the allow list |
| `GetFieldType`, `GetSchemaTable`, `GetColumnSchema` | Returns `null` for the UDT column's type rather than throwing |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Another possibility might be to have a new public type, UnregisteredUserDefinedType, and document the circumstances in which it is returned.

If so, clients sometimes use Activator.CreateInstance on the type. In such cases, having the default ctor throw would be a reasonably simple point of contact for them.


The second row is the harder one to diagnose, because `GetFieldType` does not
normally return `null`; a caller that dereferences the result sees an unrelated
`NullReferenceException`. A denial is always traced through
`SqlClientEventSource` regardless of which path was taken, so enabling event
source tracing will identify the assembly.

The remedy in every case is to name the assembly on the allow list.

### Usage Example
```csharp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ internal static class LocalAppContextSwitches
private const string UseOverallConnectTimeoutForPoolWaitString =
"Switch.Microsoft.Data.SqlClient.UseOverallConnectTimeoutForPoolWait";

/// <summary>
/// The name of the app context switch that controls whether the driver
/// loads any assembly named by a server-supplied UDT assembly-qualified
/// name, restoring the behavior that predates the UDT assembly load policy.
/// </summary>
private const string UseLegacyUdtAssemblyLoadString =
"Switch.Microsoft.Data.SqlClient.UseLegacyUdtAssemblyLoad";

#if NET
/// <summary>
/// The name of the app context switch that controls whether to use the
Expand Down Expand Up @@ -258,6 +266,11 @@ private enum SwitchValue : byte
/// </summary>
private static SwitchValue s_useOverallConnectTimeoutForPoolWait = SwitchValue.None;

/// <summary>
/// The cached value of the UseLegacyUdtAssemblyLoad switch.
/// </summary>
private static SwitchValue s_useLegacyUdtAssemblyLoad = SwitchValue.None;

#if NET
/// <summary>
/// The cached value of the UseManagedNetworking switch.
Expand Down Expand Up @@ -612,6 +625,25 @@ public static bool UseCompatibilityAsyncBehaviour
defaultValue: false,
ref s_useOverallConnectTimeoutForPoolWait);

/// <summary>
/// When set to true, the driver loads any assembly named by a
/// server-supplied UDT assembly-qualified name, and skips the check that
/// the resolved type is annotated with SqlUserDefinedTypeAttribute. This is
/// the behavior that predates the UDT assembly load policy.
///
/// Enabling it allows a server, or an attacker on the network path of a
/// connection that has opted out of certificate validation, to choose which
/// assemblies the client process loads, so it should only be used as a
/// temporary compatibility measure.
///
/// The default value of this switch is false.
/// </summary>
public static bool UseLegacyUdtAssemblyLoad =>
AcquireAndReturn(
UseLegacyUdtAssemblyLoadString,
defaultValue: false,
ref s_useLegacyUdtAssemblyLoad);

#if NET
/// <summary>
/// When set to true, .NET on Windows will use the managed SNI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;

namespace Microsoft.Data.SqlClient.Server
{
Expand Down Expand Up @@ -377,7 +378,20 @@ internal Type Type
// Fault-in UDT clr types on access if have assembly-qualified name
if (_clrType == null && SqlDbType.Udt == _databaseType && _udtAssemblyQualifiedName != null)
{
_clrType = Type.GetType(_udtAssemblyQualifiedName, true);
// The assembly-qualified name can originate from the server,
// so the resolution goes through the same policy that
// SqlConnection.ResolveTypeAssembly applies. There is no
// connection context here, so no type system version is
// available to pin the built-in SQL CLR types assembly to;
// its public key token is still pinned.
_clrType = Type.GetType(
typeName: _udtAssemblyQualifiedName,
assemblyResolver: static asmRef =>
UdtAssemblyPolicy.TryResolve(asmRef, typeSystemAssemblyVersion: null, out Assembly loaded)
? loaded ?? Assembly.Load(asmRef)
: throw SQL.UdtAssemblyNotAllowed(asmRef.Name),
Comment on lines +390 to +392
typeResolver: null,
throwOnError: true);
}
return _clrType;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3033,13 +3033,41 @@ private void CopyFrom(SqlConnection connection)
private Assembly ResolveTypeAssembly(AssemblyName asmRef, bool throwOnError)
{
Debug.Assert(TypeSystemAssemblyVersion != null, "TypeSystemAssembly should be set !");
if (string.Equals(asmRef.Name, "Microsoft.SqlServer.Types", StringComparison.OrdinalIgnoreCase))

if (UdtAssemblyPolicy.IsSqlServerTypesAssembly(asmRef) &&
asmRef.Version != TypeSystemAssemblyVersion &&
SqlClientEventSource.Log.IsTraceEnabled())
{
SqlClientEventSource.Log.TryTraceEvent("SqlConnection.ResolveTypeAssembly | SQL CLR type version change: Server sent {0}, client will instantiate {1}", asmRef.Version, TypeSystemAssemblyVersion);
}

// The assembly name arrives from the server, so the driver must
// decide whether it is willing to bring this assembly into the
// process before it hands the name to the loader. This call also
// pins the identity (version and public key token) of the built-in
// SQL CLR types assembly, so that the built-in exemption cannot be
// satisfied by a same-named assembly that happens to sit on the
// probing path.
if (!UdtAssemblyPolicy.TryResolve(asmRef, TypeSystemAssemblyVersion, out Assembly alreadyLoaded))
{
if (asmRef.Version != TypeSystemAssemblyVersion && SqlClientEventSource.Log.IsTraceEnabled())
SqlClientEventSource.Log.TryTraceEvent("SqlConnection.ResolveTypeAssembly | ERR | UDT assembly '{0}' was not loaded because the UDT assembly load policy does not permit it.", asmRef.Name);

if (throwOnError)
{
SqlClientEventSource.Log.TryTraceEvent("SqlConnection.ResolveTypeAssembly | SQL CLR type version change: Server sent {0}, client will instantiate {1}", asmRef.Version, TypeSystemAssemblyVersion);
throw SQL.UdtAssemblyNotAllowed(asmRef.Name);
}
asmRef.Version = TypeSystemAssemblyVersion;

return null;
}

// The policy permitted the reference because the process had already
// loaded an assembly of that simple name. Use that instance rather
// than binding the server-supplied version and public key token,
// which could otherwise resolve to a different assembly and cause
// the new load this policy exists to prevent.
Comment on lines +3063 to +3067
if (alreadyLoaded != null)
{
return alreadyLoaded;
}

try
Expand Down Expand Up @@ -3068,6 +3096,50 @@ internal void CheckGetExtendedUDTInfo(SqlMetaDataPriv metaData, bool fThrow)
metaData.udt.Type =
Type.GetType(typeName: metaData.udt.AssemblyQualifiedName, assemblyResolver: asmRef => ResolveTypeAssembly(asmRef, fThrow), typeResolver: null, throwOnError: fThrow);

// Nothing has executed any of the resolved type's code yet:
// reading its custom attributes does not run its static
// constructor. This is therefore the last point at which the
// driver can reject a type that the server named but that is not
// actually a user-defined type, and it must happen before
// GetUdtValue invokes anything on it.
//
// This check also backstops the assembly policy: a type name
// that carries no assembly part is resolved without ever
// consulting the assembly resolver, so this is the only gate a
// name such as "System.String" passes through.
if (metaData.udt.Type != null && !UdtAssemblyPolicy.LegacyBehaviorEnabled)
{
bool isUserDefinedType;

try
{
isUserDefinedType = SqlUdtInfo.TryGetFromType(metaData.udt.Type) != null;
}
catch (Exception e) when (ADP.IsCatchableExceptionType(e))
{
// Reading custom attributes can fail if the attribute or
// one of its arguments lives in an assembly that cannot
// be loaded. Treat that as "not a user-defined type"
// rather than letting it escape, so that callers that
// pass fThrow: false keep tolerating an unusable type.
SqlClientEventSource.Log.TryTraceEvent("SqlConnection.CheckGetExtendedUDTInfo | ERR | Unable to read the attributes of type '{0}'.", metaData.udt.AssemblyQualifiedName);

isUserDefinedType = false;
}

if (!isUserDefinedType)
{
SqlClientEventSource.Log.TryTraceEvent("SqlConnection.CheckGetExtendedUDTInfo | ERR | Type '{0}' is not annotated with SqlUserDefinedTypeAttribute and will not be used.", metaData.udt.AssemblyQualifiedName);

metaData.udt.Type = null;

if (fThrow)
{
throw SQL.UdtTypeNotUserDefined(metaData.udt.AssemblyQualifiedName);
}
}
}

if (fThrow && metaData.udt.Type == null)
{
throw SQL.UDTUnexpectedResult(metaData.udt.AssemblyQualifiedName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,16 @@ internal static Exception UDTUnexpectedResult(string exceptionText)
return ADP.TypeLoad(StringsHelper.GetString(Strings.SQLUDT_Unexpected, exceptionText));
}

internal static Exception UdtAssemblyNotAllowed(string assemblyName)
{
return ADP.TypeLoad(StringsHelper.GetString(Strings.SQLUDT_AssemblyNotAllowed, assemblyName));
}

internal static Exception UdtTypeNotUserDefined(string assemblyQualifiedName)
{
return ADP.TypeLoad(StringsHelper.GetString(Strings.SQLUDT_TypeNotUserDefined, assemblyQualifiedName));
}

internal static Exception ConversionOverflow()
{
return new OverflowException(StringsHelper.GetString(Strings.SqlMisc_ConversionOverflowMessage));
Expand Down
Loading
Loading