Harden UDT assembly loading against server-supplied assembly names - #4591
Harden UDT assembly loading against server-supplied assembly names#4591cheenamalhotra wants to merge 7 commits into
Conversation
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
…sembly-load-hardening
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
…sembly-load-hardening
There was a problem hiding this comment.
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.Typeresolver throwsSQL.UdtAssemblyNotAlloweddirectly and does not emit theSqlClientEventSourcedenial event used byResolveTypeAssembly. 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
SqlUserDefinedTypeAttributegate.ValueUtilsSmi.NullUdtInstanceinvokesmetaData.Type's staticNullmember, so a bare or permitted non-UDT name can still execute untrusted type code through SMI even thoughCheckGetExtendedUDTInfoblocks it in the main path. Validate the resolved type before storing or returning it, including names that bypassassemblyResolver.
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 aTypeLoadExceptionthroughADP.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.Evilis intentionally nonexistent, the pre-fix implementation would still callAssembly.Load, receiveFileNotFoundException, and raise noAssemblyLoadevent. 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 isIsAllowed_UnknownAssembly_IsDeniedabove. 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 | |
| UdtAssemblyPolicy.TryResolve(asmRef, typeSystemAssemblyVersion: null, out Assembly loaded) | ||
| ? loaded ?? Assembly.Load(asmRef) | ||
| : throw SQL.UdtAssemblyNotAllowed(asmRef.Name), |
| // 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. |
| private static void PinSqlServerTypesIdentity(AssemblyName asmRef, Version? typeSystemAssemblyVersion) | ||
| { | ||
| if (typeSystemAssemblyVersion is not null) | ||
| { | ||
| asmRef.Version = typeSystemAssemblyVersion; | ||
| } | ||
|
|
||
| asmRef.SetPublicKeyToken((byte[])s_sqlServerTypesPublicKeyToken.Clone()); |
| byte[]? allowedToken = allowed.GetPublicKeyToken(); | ||
| if (allowedToken is { Length: > 0 }) | ||
| { |
| /// 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()); |
| /// 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 |
| { | ||
| // 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; |
Description
When the driver materializes a UDT value, the type's assembly-qualified name comes from the server. That name previously flowed into
Assembly.LoadandType.GetTypewithout 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:
Microsoft.SqlServer.TypesEverything 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:
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
SqlUserDefinedTypeAttributeis now rejected before any of its code runs. This is the gate that actually prevents foreign code execution. I verified empirically on CoreCLR that neitherAssembly.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 whatGetUdtValuewould otherwise perform. So the attribute check sits in front of the only step that executes code.SmiMetaData.Typehad 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 passnulltoday.Escape hatch
Switch.Microsoft.Data.SqlClient.UseLegacyUdtAssemblyLoadrestores 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 ownAssembly.Loadwas 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:
reader[i],GetValue, UDT output parametersSqlExceptionnaming the assembly and the allow listGetFieldType,GetSchemaTable,GetColumnSchemanullfor the UDT column's type rather than throwingThe second row follows the pre-existing
fThrow: falsecontract on those paths, so I preserved it rather than changing unrelated behavior in a security fix. It is harder to diagnose, becauseGetFieldTypedoes not normally returnnulland a caller that dereferences the result sees an unrelatedNullReferenceException. To compensate, every denial is traced throughSqlClientEventSourceregardless 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.mddocuments the switch, the permitted set, and a "Compatibility impact" section covering both symptom shapes.Two new resource strings (
SQLUDT_AssemblyNotAllowed,SQLUDT_TypeNotUserDefined) are added toStrings.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.cscovers the policy in isolation: enforcement, the pinnedMicrosoft.SqlServer.Typesidentity (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. IncludesResolve_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.csdrivesCheckGetExtendedUDTInfoend 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
AppContextSwitchTestCollectionso 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 inNativeColumnEncryptionKeyBaselineunrelated to this change.Gap: the
net462leg 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