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
137 changes: 137 additions & 0 deletions .github/instructions/features.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,143 @@ 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, the culture to neutral, 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, culture 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.

Normalizing the reference is necessary but not sufficient. On .NET the loader
**ignores** the public key token in an `AssemblyName`, and can satisfy a request
with a different version than the one asked for, so pinning the reference does
not by itself determine what arrives. A custom `AssemblyResolve` handler or
`AssemblyLoadContext` resolver can go further still and answer with an assembly
of an entirely different name. The driver therefore verifies the identity of the
assembly the loader actually hands back against every component the decision
relied on, including the simple name that the permission was granted to, and
refuses it on any mismatch. This mirrors what the driver already does for the
Azure authentication extension assembly.

On .NET, the already-loaded tier is scoped to the `AssemblyLoadContext` that
loaded the driver, since that is the context its `Assembly.Load` calls resolve
into. An application that loads its UDT assembly into a separate (for example
collectible) context must name it on the allow list. The driver holds only weak
references to the assemblies it has observed, so this policy never prevents a
collectible context from unloading.

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. An entry that explicitly specifies `PublicKeyToken=null`
requires an unsigned assembly and is not satisfied by a signed one; this is
distinct from omitting the token, which places no constraint on it.

Independently of the assembly policy, a resolved type that is not annotated with
`SqlUserDefinedTypeAttribute` is rejected before any member of it is accessed
(except under `UseLegacyUdtAssemblyLoad`). This is the gate that actually
prevents foreign code execution.

On CoreCLR this has been measured directly: 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. The
attribute check therefore sits in front of the only step that executes code.

Module initializer timing on .NET Framework has not been measured, and ECMA-335
permits a runtime to run one earlier than CoreCLR does. The portable guarantee
is the one stated above — no member of the type is accessed before the attribute
check — rather than a claim about exactly when the runtime chooses to run
initializers.

Note that the attribute check itself does not execute foreign code.
`SqlUserDefinedTypeAttribute` is `sealed`, so it cannot be subclassed by a
hostile assembly, and the lookup is filtered to that single attribute type, so
the constructors of any other attributes on the type are never invoked.

#### Trust is per process, not per server

The already-loaded tier makes the permitted set a property of the process rather
than of the connection. Once an assembly is loaded by any means, a UDT type
within it can be instantiated on the say-so of any server the process connects
to, whether or not that assembly was loaded for that server's benefit. The
resolved type must still carry `SqlUserDefinedTypeAttribute`, so this is
confined to types that were written to be deserialized from SQL Server, but it
is a genuine widening and is called out here deliberately.

Relatedly, the map of loaded assemblies is snapshotted before the policy can
trigger any load of its own, and loads the policy performs are excluded from it
thereafter. Neither is merely a performance choice. Rebuilding the map on
demand, snapshotting it lazily after a permitted load had already run, or
recording the dependencies that arrive alongside a permitted assembly, would all
let an assembly that was pulled in as a *dependency* of a permitted assembly
silently inherit that permission. Together they keep the tier anchored to what
the application loaded of its own accord.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks - I worked through these, and one of them does not behave the way you expected.

Not affected, for the reason you give (we move bytes without interpreting them):

  • SqlBulkCopy to a UDT column: SqlBulkCopy.cs:853 maps UDT to varbinary in the bulk command text.
  • SqlBulkCopy from a DataTable: values arrive as objects the application already holds.
  • SqlBulkCopy to varbinary(max): no type resolution.

Affected, contrary to expectation:

  • SqlBulkCopy from a SqlDataReader between UDT columns. SqlBulkCopy.cs:1241 calls _sqlDataReaderRowSource.GetValue(sourceOrdinal) so it can test the value for INullable. GetValue materializes the UDT, so it goes through the policy, and a denial surfaces as a TypeLoadException mid-copy.
  • Table-valued parameters sourced from a SqlDataReader. SqlParameter.cs:1295 calls GetInternalSmiMetaData, which hits SqlDataReader.cs:310 with fThrow: true.

So the "transferring bytes without interpretation" intuition holds everywhere except where we need INullable or SMI metadata, and in both of those we materialize the type.

SqlCommandBuilder I could not find a UDT type-resolution path in at all - it works from column metadata names rather than CLR types - so I believe it is unaffected, but I would value a second opinion since you raised it.

I will fold the two affected cases into the compatibility documentation.

|-----|---------|
| `reader[i]`, `GetValue`, UDT output parameters | `TypeLoadException` 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I like this, and it is a better answer than what the PR currently does for the silent-null path.

The appeal is that it removes the worst diagnostic problem here. Right now GetFieldType returning null on a denied UDT is nearly undiagnosable: a caller dereferences it and sees a NullReferenceException with no connection to the actual cause. A sentinel type keeps the non-throwing contract those call sites rely on while still carrying the explanation, and a throwing default constructor gives Activator.CreateInstance callers a precise point of contact, as you say.

Two things I would want to settle before doing it:

  • It is new public API surface, so it needs API review, and this PR is on an MSRC release schedule. I would rather not couple the two.
  • The sentinel would flow into GetSchemaTable().DataType and GetColumnSchema().SqlDataType, so we should decide deliberately what those should show. A type whose name states the problem is arguably an improvement over null, but it is a visible change to schema output either way.

My suggestion is to ship the deny-by-default boundary here and do UnregisteredUserDefinedType as a focused follow-up with proper API review, rather than rush a public type into a security fix. If you would rather it land together I am happy to add it - your call as maintainer.


Two less obvious paths also materialize the type and are therefore affected:

- `SqlBulkCopy` **from a `SqlDataReader`** between UDT columns. The copy reads
each value so it can test it for `INullable`, which materializes the UDT.
Copying *to* a UDT column from a `DataTable`, or to `varbinary(max)`, does not
resolve the type and is unaffected.
- Table-valued parameters sourced from a `SqlDataReader`, which build SMI
metadata and resolve the UDT type with throwing enabled.

The exception is a `TypeLoadException` and is not wrapped in a `SqlException`,
which matches how the driver already reports a UDT type it cannot resolve.

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,9 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using Microsoft.Data.Common;
using Microsoft.Data.SqlClient.Internal;

namespace Microsoft.Data.SqlClient.Server
{
Expand Down Expand Up @@ -377,12 +380,75 @@ 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 culture and public key token are still pinned, and the
// policy verifies the identity of whatever the loader
// returns.
Type resolved = Type.GetType(
typeName: _udtAssemblyQualifiedName,
assemblyResolver: static asmRef =>
UdtAssemblyPolicy.TryLoad(asmRef, typeSystemAssemblyVersion: null, out Assembly loaded)
? loaded
: throw UdtAssemblyDenied(asmRef),
typeResolver: null,
throwOnError: true);

// A name that carries no assembly part never reaches the
// assembly resolver above, so the attribute gate is the only
// thing standing between a server-chosen type name and
// ValueUtilsSmi.NullUdtInstance invoking its static Null
// member. Apply it here as SqlConnection does on the main
// path, so this route cannot be used to run the code of a
// type that is not actually a user-defined type.
if (resolved != null && !UdtAssemblyPolicy.LegacyBehaviorEnabled && !IsUserDefinedType(resolved))
{
SqlClientEventSource.Log.TryTraceEvent(
"SmiMetaData.Type | ERR | Type '{0}' is not annotated with SqlUserDefinedTypeAttribute and will not be used.",
_udtAssemblyQualifiedName);

throw SQL.UdtTypeNotUserDefined(_udtAssemblyQualifiedName);
}
Comment thread
cheenamalhotra marked this conversation as resolved.

_clrType = resolved;
}
return _clrType;
}
}

/// <summary>
/// Traces and builds the exception for an assembly the UDT policy
/// refused, so that a denial on this path is observable through event
/// source tracing exactly as it is on the SqlConnection path.
/// </summary>
private static Exception UdtAssemblyDenied(AssemblyName asmRef)
{
SqlClientEventSource.Log.TryTraceEvent(
"SmiMetaData.Type | ERR | UDT assembly '{0}' was not loaded because the UDT assembly load policy does not permit it.",
asmRef.Name);

return SQL.UdtAssemblyNotAllowed(asmRef.Name);
}

/// <summary>
/// Determines whether a resolved type is annotated as a user-defined
/// type, tolerating an attribute that cannot be read.
/// </summary>
private static bool IsUserDefinedType(Type type)
{
try
{
return SqlUdtInfo.TryGetFromType(type) != null;
}
catch (Exception e) when (ADP.IsCatchableExceptionType(e))
{
return false;
}
}

internal bool IsMultiValued => _isMultiValued;

// Returns read-only list of field metadata
Expand Down
Loading
Loading