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.

#### 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 |
|-----|---------|
| `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 |

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