Skip to content

Harden UDT assembly loading against server-supplied assembly names - #4591

Draft
cheenamalhotra wants to merge 7 commits into
mainfrom
dev/automation/udt-assembly-load-hardening
Draft

Harden UDT assembly loading against server-supplied assembly names#4591
cheenamalhotra wants to merge 7 commits into
mainfrom
dev/automation/udt-assembly-load-hardening

Conversation

@cheenamalhotra

@cheenamalhotra cheenamalhotra commented Aug 24, 2026

Copy link
Copy Markdown
Member

Description

When the driver materializes a UDT value, the type's assembly-qualified name comes from the server. That name previously flowed into Assembly.Load and Type.GetType without the driver making any decision about it, so the set of assemblies a connection could pull into the process was effectively chosen by the server rather than by the application.

This PR puts the application back in control with a deny-by-default policy, plus a validation gate on the resolved type.

Policy (UdtAssemblyPolicy)

A single enforcing behavior that permits exactly three things:

Permitted Notes
Microsoft.SqlServer.Types Identity pinned. Version is normalized to the connection's negotiated type system version and the public key token to the one Microsoft signs with, so the built-in exemption cannot be satisfied by a same-named assembly sitting on the probing path.
Assemblies on an application-supplied allow list The app 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. Notably, an assembly that is only statically referenced by a loaded assembly is not permitted, because loading it is a genuinely new load, which is precisely the decision this keeps with the application.

Applications configure the allow list through an AppContext data element:

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

Each entry matches only on the components it specifies, so a simple name permits any version/culture/PKT while a fully-qualified name must match exactly.

Type validation

Independently of the assembly decision, a resolved type that is not annotated with SqlUserDefinedTypeAttribute is now rejected before any of its code runs. This is the gate that actually prevents foreign code execution. I verified empirically on CoreCLR that neither Assembly.Load, nor resolving a type, nor reading that type's custom attributes runs anything from the target assembly. A module initializer or static constructor runs on first real member access, which is what GetUdtValue would otherwise perform. So the attribute check sits in front of the only step that executes code.

SmiMetaData.Type had a second, latent sink for the same pattern; it is now routed through the same policy as defense in depth, even though all live callers pass null today.

Escape hatch

Switch.Microsoft.Data.SqlClient.UseLegacyUdtAssemblyLoad restores the previous behavior in full. It is intended as a temporary compatibility measure, not a supported configuration, and is documented as such.

Backwards compatibility

This is a behavior change for applications using custom UDTs, and I did not find a way to avoid it without leaving the hole open. Flagging it prominently rather than burying it.

The built-in spatial types (SqlGeography, SqlGeometry, SqlHierarchyId) are unaffected.

An application is affected when the custom UDT's assembly is not already loaded at the moment the value is read. That happens whenever the driver materializes the value and the app 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 the only thing pulling the assembly in. If the app statically names the UDT type, the JIT loads the assembly first and everything still works.

Two different symptom shapes, and the second is the one worth reviewer attention:

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 follows the pre-existing fThrow: false contract on those paths, so I preserved it rather than changing unrelated behavior in a security fix. It is harder to diagnose, because GetFieldType does not normally return null and a caller that dereferences the result sees an unrelated NullReferenceException. To compensate, every denial is traced through SqlClientEventSource regardless of which path was taken, so event source tracing will always identify the assembly. I would welcome a second opinion on whether that trade is right, or whether these paths should throw despite the contract.

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

Documentation and localization

.github/instructions/features.instructions.md documents the switch, the permitted set, and a "Compatibility impact" section covering both symptom shapes.

Two new resource strings (SQLUDT_AssemblyNotAllowed, SQLUDT_TypeNotUserDefined) are added to Strings.resx. Localization will pick these up through the normal OneLocBuild flow after this merges.

No public API surface changes, so no ref/ updates are needed.

Issues

Tracked internally via the MSRC case and its linked ADO repair item. Intentionally not linking a public issue here while the case is under coordinated release.

Testing

Two new unit test files, 85 tests total across the policy and the switch:

  • UdtAssemblyPolicyTest.cs covers the policy in isolation: enforcement, the pinned Microsoft.SqlServer.Types identity (including rejecting a same-named assembly with the wrong PKT/version), deny-by-default, allow list matching at each level of qualification, and the already-loaded tier. Includes Resolve_LoadedAssembly_IgnoresServerSuppliedIdentity, which covers a bug found during self-review where a loaded assembly was matched on simple name but then loaded using the server's full reference, letting a server force a new load of a different version.
  • UdtAssemblyLoadHardeningTest.cs drives CheckGetExtendedUDTInfo end to end with hostile assembly-qualified names and asserts no load occurred. It also asserts the attribute gate rejects a non-UDT type without running its static constructor, reading the marker flag from a separate class so the assertion is meaningful.

Both test classes join AppContextSwitchTestCollection so they serialize with the other AppContext-mutating tests.

Also added coverage for a structural limitation worth knowing about: a bare type name with no assembly part never reaches the assembly resolver at all, so only the attribute gate stops it. Three regression tests pin that behavior.

Validation performed: clean build at 0 warnings under TreatWarningsAsErrors; full unit suite 992 passed / 9 skipped / 3 failed, where the 3 failures are pre-existing macOS keychain issues in NativeColumnEncryptionKeyBaseline unrelated to this change.

Gap: the net462 leg cannot be built on macOS, so it needs CI to validate. Related open question: I measured module-initializer timing only on CoreCLR. ECMA-335 permits a runtime to run module initializers at load time, and .NET Framework is unverified. A different result there would mean softening how the docs frame the ordering, but the design is safe either way since the attribute gate still runs before any member access.

Guidelines

  • Tests added
  • Public API changes documented (no public API surface change)
  • Ensure no breaking changes introduced - intentionally unchecked, see "Backwards compatibility" above

cheenamalhotra and others added 7 commits July 28, 2026 13:50
SqlConnection.ResolveTypeAssembly handed the assembly name carried by a
server-supplied UDT assembly-qualified name straight to Assembly.Load,
and GetUdtValue then invoked a static member on the resolved type
without checking that it was a user-defined type at all. Loading an
assembly runs its module initializer and invoking a static member runs
the type's static constructor, so a compromised or hostile server -- or
an attacker on the network path of a connection that has opted out of
certificate validation -- could choose which code the client process
executes.

Add a deny-by-default policy that decides whether an assembly may be
loaded before the name reaches the loader:

- Restricted (default) permits Microsoft.SqlServer.Types, the
  application's allow list, assemblies already loaded into the process,
  and assemblies statically referenced by them.
- Strict, via Switch.Microsoft.Data.SqlClient.UseStrictUdtAssemblyLoad,
  drops the loaded/referenced allowance.
- Legacy, via Switch.Microsoft.Data.SqlClient.UseLegacyUdtAssemblyLoad,
  restores the previous behavior as a compatibility escape hatch and
  takes precedence over Strict.

The Microsoft.SqlServer.Types exemption now pins the public key token as
well as the version, so it cannot be satisfied by a same-named assembly
on the probing path.

Independently of the mode, CheckGetExtendedUDTInfo now rejects a
resolved type that is not annotated with SqlUserDefinedTypeAttribute.
Reading custom attributes does not run a static constructor, so this is
the last point at which the driver can decline without executing any of
the type's code, and it covers every call site uniformly.

Applications with lazily-loaded custom UDT assemblies can name them
through the Microsoft.Data.SqlClient.UdtAssemblyAllowList AppContext
data element.

The known-assembly-name set is cached and invalidated only by
AppDomain.AssemblyLoad, so a server streaming distinct names costs a
hash lookup rather than a probe.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60806d12-bdac-413c-bf83-f7c12e3cc12c
Fixes six issues found while reviewing the initial hardening commit:

- CheckGetExtendedUDTInfo now wraps SqlUdtInfo.TryGetFromType in a
  catch, so attribute resolution failures do not start throwing at the
  fThrow: false call sites (GetFieldType and the provider-specific field
  type) that previously tolerated an unresolvable UDT.
- SmiMetaData.Type's assembly-qualified name fallback was a second,
  ungated Type.GetType sink. It now resolves through the same policy.
  Every reachable caller currently passes null, so this is defence in
  depth rather than a live hole.
- Pinning the identity of Microsoft.SqlServer.Types is now folded into
  UdtAssemblyPolicy.IsAllowed, so it is not possible to consult the
  policy without also pinning. The built-in exemption is granted on the
  simple name alone, so an unpinned reference would have let an unsigned
  assembly borrow the name.
- The known-assembly-name set is now maintained incrementally by the
  AssemblyLoad handler instead of being rebuilt on every load, removing
  a full enumeration of the process's assemblies and their reference
  lists from the hot path.
- Adds regression tests for a type name that carries no assembly part.
  Type.GetType resolves such a name without ever consulting the assembly
  resolver, so the SqlUserDefinedTypeAttribute check is the only gate it
  passes through; the tests lock that in for both fThrow values.
- Verified that an exception thrown from inside the assembly resolver
  propagates out of Type.GetType unwrapped for both throwOnError values,
  so SQL.UdtAssemblyNotAllowed reaches the caller intact.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60806d12-bdac-413c-bf83-f7c12e3cc12c
The merge of origin/main brought in PR #4495, which introduced
AppContextSwitchTestCollection to serialize tests that mutate
process-wide cached AppContext switch values. Both UDT test classes do
exactly that, so they join the collection; without it they can race
against other collections and observe each other's temporary settings.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60806d12-bdac-413c-bf83-f7c12e3cc12c
Replaces the Restricted/Strict/Legacy taxonomy with one enforcing
behavior plus the legacy escape hatch, and removes the
UseStrictUdtAssemblyLoad switch.

The three-mode design was over-built for what this layer does. Measured
on CoreCLR, neither Assembly.Load, nor resolving a type from the loaded
assembly, nor reading that type's custom attributes executes any code
from the target; a module initializer runs on first real member access,
which is what GetUdtValue would perform. The SqlUserDefinedTypeAttribute
check in CheckGetExtendedUDTInfo is therefore the gate that actually
prevents foreign code execution, and the assembly policy in front of it
is a resource-load gate that does not warrant two tiers.

The single enforcing mode permits the pinned Microsoft.SqlServer.Types
assembly, the application's allow list, and assemblies already loaded
into the process. The static reference closure is no longer permitted,
because loading a referenced-but-unloaded assembly is a genuinely new
load, which is the thing this policy exists to keep under the
application's control. Applications with custom UDTs whose assembly is
not yet loaded must now name it on the allow list.

Also fixes an identity-binding hole in the already-loaded tier. It
matched on simple name and then handed the server's full reference,
including version and public key token, to the loader, so a server could
name a loaded simple name with a different identity and still trigger a
new load. The policy now returns the loaded instance itself, and
callers use it rather than re-binding server-controlled identity.

Note that ECMA-335 permits a runtime to run a module initializer at load
time, and only CoreCLR was measured here, so the assembly policy is
retained as defence in depth pending verification on .NET Framework.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60806d12-bdac-413c-bf83-f7c12e3cc12c
Records the decision to ship the single enforcing mode as-is and accept
the compatibility break rather than staging the reference closure behind
a deprecation release.

Documents which applications are affected, why the affected shape is
common (the driver materializes the value and the application never
names the UDT type itself, so the driver's own Assembly.Load was
previously what pulled the assembly in), and both symptom shapes. The
fThrow: false path is called out specifically, because GetFieldType,
GetSchemaTable and GetColumnSchema return null for a denied UDT column
rather than throwing, and a caller that dereferences the result sees an
unrelated NullReferenceException. Denials are always traced, so event
source tracing identifies the assembly in either case.

No behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60806d12-bdac-413c-bf83-f7c12e3cc12c
Copilot AI lite review requested due to automatic review settings August 24, 2026 22:24
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Aug 24, 2026

Copilot AI left a comment

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.

Pull request overview

Hardens server-supplied UDT assembly resolution with deny-by-default loading, type validation, compatibility controls, diagnostics, and tests.

Changes:

  • Adds assembly policy enforcement and allow-list support.
  • Validates UDT types before materialization.
  • Adds a legacy switch, localized errors, documentation, and regression tests.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 9 comments.

Show a summary per file
File Summary Final review findings
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyPolicyTest.cs Tests policy enforcement and matching. Moderate (3 votes): A scenario can silently pass without assertions when dependencies are already loaded.
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyLoadHardeningTest.cs Tests end-to-end load prevention and type validation. Nit (2 votes): Correct the comment describing CoreCLR module-initializer and code-execution timing.
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs Tests switch behavior. No final comments.
src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs Supports switch testing. No final comments.
src/Microsoft.Data.SqlClient/src/Resources/Strings.resx Adds localized UDT error messages. No final comments.
src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs Provides generated resource accessors. No final comments.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UdtAssemblyPolicy.cs Implements assembly-load policy and allow-list matching. Critical (2 votes): Post-load identity is not validated. Critical (1 vote): Culture remains server-controlled for the built-in identity. Critical (4 votes): Explicit PublicKeyToken=null is treated like an omitted token. Moderate (3 votes): Global strong references can prevent collectible assemblies from unloading.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs Adds UDT policy exception helpers. No final comments.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs Applies policy during UDT resolution. Critical (2 votes): Assembly.Load may ignore the requested token; validate the actual loaded identity.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs Routes secondary resolution through the policy. Critical (2 votes): The fallback load also lacks post-load identity validation.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs Defines the legacy compatibility switch. No final comments.
.github/instructions/features.instructions.md Documents policy and compatibility behavior. Nit (2 votes): The documented exception type is incorrect for direct reader and parameter paths.
Files not reviewed (1)
  • src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs: Generated file
Suppressed comments (6)

.github/instructions/features.instructions.md:301

  • The unconditional claim that the type is rejected before any code runs is not established for net462: the tests and description only measured CoreCLR, and the PR notes that .NET Framework module-initializer timing is unverified. Qualify this guarantee by runtime or state the portable guarantee in terms of no member access before the attribute check, so the security documentation does not promise an ordering that has not been verified.
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

.github/instructions/features.instructions.md:329

  • The new SmiMetaData.Type resolver throws SQL.UdtAssemblyNotAllowed directly and does not emit the SqlClientEventSource denial event used by ResolveTypeAssembly. Therefore a denial on this path is not traced despite the documentation claiming that every denial is identifiable through event-source tracing. Either add the same trace before throwing or narrow this statement to the paths that actually log the denial.
`NullReferenceException`. A denial is always traced through
`SqlClientEventSource` regardless of which path was taken, so enabling event
source tracing will identify the assembly.

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs:394

  • This path applies the assembly policy but never applies the new SqlUserDefinedTypeAttribute gate. ValueUtilsSmi.NullUdtInstance invokes metaData.Type's static Null member, so a bare or permitted non-UDT name can still execute untrusted type code through SMI even though CheckGetExtendedUDTInfo blocks it in the main path. Validate the resolved type before storing or returning it, including names that bypass assemblyResolver.
                            UdtAssemblyPolicy.TryResolve(asmRef, typeSystemAssemblyVersion: null, out Assembly loaded)
                                ? loaded ?? Assembly.Load(asmRef)
                                : throw SQL.UdtAssemblyNotAllowed(asmRef.Name),
                        typeResolver: null,
                        throwOnError: true);

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs:585

  • The PR description says denied UDT materialization produces a SqlException, but this new helper constructs a TypeLoadException through ADP.TypeLoad (and the non-UDT type helper does the same). That is user-visible error behavior, so either the implementation needs to throw the promised exception type or the description and tests should be corrected to document the actual contract.
        internal static Exception UdtAssemblyNotAllowed(string assemblyName)
        {
            return ADP.TypeLoad(StringsHelper.GetString(Strings.SQLUDT_AssemblyNotAllowed, assemblyName));

src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyLoadHardeningTest.cs:65

  • Because Contoso.Evil is intentionally nonexistent, the pre-fix implementation would still call Assembly.Load, receive FileNotFoundException, and raise no AssemblyLoad event. Thus these assertions pass even when the loader is reached and do not prove that the policy short-circuits the load. Use a real same-name test assembly/loader hook or assert a policy-specific failure/trace.
        Assert.NotNull(exception);
        Assert.Null(metaData.udt.Type);
        Assert.DoesNotContain("Contoso.Evil", recorder.LoadedNames);

src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyPolicyTest.cs:300

  • The fallback comment refers to Resolve_UnknownAssembly_IsDenied, but that test does not exist; the companion method is IsAllowed_UnknownAssembly_IsDenied above. Correct the reference so maintainers can locate the intended deny-path coverage.
            // Resolve_UnknownAssembly_IsDenied covers the general deny path.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


| API | Symptom |
|-----|---------|
| `reader[i]`, `GetValue`, UDT output parameters | `SqlException` naming the assembly and the allow list |
Comment on lines +390 to +392
UdtAssemblyPolicy.TryResolve(asmRef, typeSystemAssemblyVersion: null, out Assembly loaded)
? loaded ?? Assembly.Load(asmRef)
: throw SQL.UdtAssemblyNotAllowed(asmRef.Name),
Comment on lines +3063 to +3067
// 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 +240 to +247
private static void PinSqlServerTypesIdentity(AssemblyName asmRef, Version? typeSystemAssemblyVersion)
{
if (typeSystemAssemblyVersion is not null)
{
asmRef.Version = typeSystemAssemblyVersion;
}

asmRef.SetPublicKeyToken((byte[])s_sqlServerTypesPublicKeyToken.Clone());
Comment on lines +313 to +315
byte[]? allowedToken = allowed.GetPublicKeyToken();
if (allowedToken is { Length: > 0 })
{
Comment on lines +103 to +107
/// Maps the simple name of every assembly loaded into the process to the
/// loaded instance. Null when it has not been built yet.
///
/// The instance is retained, not just the name, so that a reference which
/// is permitted because the process has already loaded that simple name is
asmRef.Version = typeSystemAssemblyVersion;
}

asmRef.SetPublicKeyToken((byte[])s_sqlServerTypesPublicKeyToken.Clone());
Comment on lines +20 to +24
/// Before the fix, <see cref="SqlConnection"/> handed any server-supplied
/// assembly name straight to <see cref="Assembly.Load(AssemblyName)"/>, which
/// runs the target assembly's module initializer, and then invoked a static
/// member on the resolved type without checking that it was a user-defined type
/// at all, which runs the type's static constructor. These tests assert that
Comment on lines +297 to +301
{
// Every referenced assembly happens to be loaded in this run, so
// there is nothing here to distinguish. The companion test
// Resolve_UnknownAssembly_IsDenied covers the general deny path.
return;
@cheenamalhotra cheenamalhotra added this to the 8.0.0-preview1 milestone Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To triage

Development

Successfully merging this pull request may close these issues.

2 participants