diff --git a/.github/instructions/features.instructions.md b/.github/instructions/features.instructions.md index 34262b8db6..761f800425 100644 --- a/.github/instructions/features.instructions.md +++ b/.github/instructions/features.instructions.md @@ -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 | +|-----|---------| +| `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 | + +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 diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs index 06bf6c4f0e..37fb25f003 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs @@ -139,6 +139,14 @@ internal static class LocalAppContextSwitches private const string UseOverallConnectTimeoutForPoolWaitString = "Switch.Microsoft.Data.SqlClient.UseOverallConnectTimeoutForPoolWait"; + /// + /// 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. + /// + private const string UseLegacyUdtAssemblyLoadString = + "Switch.Microsoft.Data.SqlClient.UseLegacyUdtAssemblyLoad"; + #if NET /// /// The name of the app context switch that controls whether to use the @@ -258,6 +266,11 @@ private enum SwitchValue : byte /// private static SwitchValue s_useOverallConnectTimeoutForPoolWait = SwitchValue.None; + /// + /// The cached value of the UseLegacyUdtAssemblyLoad switch. + /// + private static SwitchValue s_useLegacyUdtAssemblyLoad = SwitchValue.None; + #if NET /// /// The cached value of the UseManagedNetworking switch. @@ -612,6 +625,25 @@ public static bool UseCompatibilityAsyncBehaviour defaultValue: false, ref s_useOverallConnectTimeoutForPoolWait); + /// + /// 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. + /// + public static bool UseLegacyUdtAssemblyLoad => + AcquireAndReturn( + UseLegacyUdtAssemblyLoadString, + defaultValue: false, + ref s_useLegacyUdtAssemblyLoad); + #if NET /// /// When set to true, .NET on Windows will use the managed SNI diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs index f2d430f2e2..a0317ed6fb 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs @@ -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 { @@ -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); + } + + _clrType = resolved; } return _clrType; } } + /// + /// 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. + /// + 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); + } + + /// + /// Determines whether a resolved type is annotated as a user-defined + /// type, tolerating an attribute that cannot be read. + /// + 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 diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs index b8b693132e..a1530713fe 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -3033,18 +3033,39 @@ 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()) { - if (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); - } - asmRef.Version = TypeSystemAssemblyVersion; + 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. The policy performs the load itself so that the + // identity of whatever the loader returns is verified; on .NET the + // loader ignores the public key token in the reference, so pinning + // it above is not by itself an enforcement boundary. try { - return Assembly.Load(asmRef); + if (!UdtAssemblyPolicy.TryLoad(asmRef, TypeSystemAssemblyVersion, out Assembly resolved)) + { + 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) + { + throw SQL.UdtAssemblyNotAllowed(asmRef.Name); + } + + return null; + } + + return resolved; } catch (Exception e) { @@ -3068,6 +3089,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); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs index c9388a42f1..5961532711 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs @@ -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)); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UdtAssemblyPolicy.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UdtAssemblyPolicy.cs new file mode 100644 index 0000000000..6c44210523 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UdtAssemblyPolicy.cs @@ -0,0 +1,898 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +#if NET +using System.Runtime.Loader; +#endif +using Microsoft.Data.Common; +using Microsoft.Data.SqlClient.Internal; + +#nullable enable + +namespace Microsoft.Data.SqlClient; + +/// +/// Decides whether the driver may load an assembly named by a server-supplied +/// UDT assembly-qualified name. +/// +/// A TDS response describing a UDT column or output parameter carries an +/// AssemblyQualifiedName that the driver must resolve to a CLR +/// . A server (or an on-path attacker against a connection +/// that has opted out of certificate validation) therefore gets to choose which +/// assembly the client process loads unless the driver constrains the choice, +/// which is what this class does. +/// +/// There is a single enforcing behavior. An assembly may be loaded when it is +/// the built-in Microsoft.SqlServer.Types assembly with its identity +/// pinned, when the application has named it on the allow list, or when it is +/// already loaded into the process. Everything else is refused. +/// +/// The already-loaded case is free: re-loading an assembly that the process has +/// already loaded returns the existing instance and introduces nothing new. +/// Assemblies that are merely statically referenced are deliberately *not* +/// permitted, because loading one is a genuinely new load, which is the thing +/// this policy exists to keep under the application's control rather than the +/// server's. An application whose custom UDT assembly is not loaded at the time +/// its first UDT value arrives must name it on the allow list. +/// +/// Note that loading an assembly is not by itself the point at which foreign +/// code runs: on CoreCLR neither , nor +/// resolving a type from it, nor reading that type's custom attributes executes +/// anything from the target assembly; a module initializer runs on first real +/// access to a member. That final gate is +/// SqlConnection.CheckGetExtendedUDTInfo, which requires +/// SqlUserDefinedTypeAttribute before GetUdtValue may invoke +/// anything. This class is the layer in front of it, limiting which assemblies +/// a server can cause to be pulled into the process at all. +/// +/// The evaluation is deliberately cheap: apart from a one-time subscription to +/// , a decision is a dictionary lookup. The +/// map of loaded assemblies is maintained incrementally, so a hostile server +/// that streams a large number of distinct assembly names cannot force repeated +/// enumeration or disk probing. +/// +internal static class UdtAssemblyPolicy +{ + #region Constants + + /// + /// The simple name of the assembly that ships the built-in SQL Server CLR + /// types (geography, geometry, hierarchyid). It is always permitted, but + /// only with the identity pinned by + /// . + /// + internal const string SqlServerTypesAssemblyName = "Microsoft.SqlServer.Types"; + + /// + /// The name of the AppContext data element that holds the application's + /// UDT assembly allow list. The value is a string containing one or more + /// assembly names separated by semicolons. An entry may be a simple name + /// (Contoso.Udts), in which case only the simple name is compared, + /// or a full assembly name + /// (Contoso.Udts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=...), + /// in which case every component that the entry specifies must also match. + /// + internal const string AllowListAppContextDataName = + "Microsoft.Data.SqlClient.UdtAssemblyAllowList"; + + /// + /// The public key token that every shipped build of + /// Microsoft.SqlServer.Types is signed with. + /// + private static readonly byte[] s_sqlServerTypesPublicKeyToken = + { 0x89, 0x84, 0x5d, 0xcd, 0x80, 0x80, 0xcc, 0x91 }; + + #if NET + /// + /// The load context that the driver's own + /// calls resolve into, which is the one that loaded the driver itself. + /// + private static readonly AssemblyLoadContext? s_driverLoadContext = + AssemblyLoadContext.GetLoadContext(typeof(UdtAssemblyPolicy).Assembly); + #endif + + #endregion + + #region Fields + + /// + /// Guards the cached allow list and known-assembly-name set. + /// + private static readonly object s_lock = new(); + + /// + /// Set to true once the handler has + /// been attached. The handler is attached lazily so that applications that + /// never read a UDT value never pay for it. + /// + private static bool s_assemblyLoadHandlerAttached; + + /// + /// 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 + /// satisfied with the assembly the process actually holds. Binding the + /// server-supplied version, culture and public key token instead would let + /// a server name a loaded simple name with a different identity and thereby + /// still trigger a new load, which is exactly what this tier must not do. + /// + /// The reference is weak. A strong one would keep every assembly in the + /// map alive for the life of the process, which would prevent a collectible + /// AssemblyLoadContext from ever unloading: a server could force the + /// map to be built with a single denied UDT and thereby pin unrelated + /// plugin assemblies. A dead entry simply drops out and the reference is + /// re-evaluated as if the assembly had never been loaded. + /// + /// When several assemblies share a simple name, the first one seen wins. + /// All of them are already in the process, so the choice cannot widen the + /// policy; at worst the subsequent type lookup fails. + /// + private static Dictionary>? s_loadedAssemblies; + + /// + /// The raw allow list string that was parsed + /// from, used to detect that the application has changed it. + /// + private static string? s_allowListSource; + + /// + /// The parsed allow list. Null when it has not been parsed yet. + /// + private static List? s_allowList; + + /// + /// Non-zero on a thread that is inside the policy's own call to + /// . + /// + /// Loading a permitted assembly also loads whatever that assembly needs, and + /// those dependency loads raise just as + /// an application-initiated load does. Recording them would let a server + /// name a dependency afterwards and have it permitted as "already loaded", + /// which is precisely the transitive trust this policy refuses: an assembly + /// that is merely reachable from a permitted one is documented as denied. + /// + /// Marking the window lets the handler tell the two apart, so permission + /// cannot spread from an allow-listed assembly to its closure. + /// + [ThreadStatic] + private static int t_insidePolicyLoad; + + #endregion + + #region Properties + + /// + /// True when the policy has been disabled entirely in favor of the + /// pre-policy behavior, in which any assembly the server names may be + /// loaded and no user-defined type check is performed. + /// + internal static bool LegacyBehaviorEnabled => + LocalAppContextSwitches.UseLegacyUdtAssemblyLoad; + + #endregion + + #region Methods + + /// + /// Determines whether names the built-in SQL + /// Server CLR types assembly. + /// + internal static bool IsSqlServerTypesAssembly(AssemblyName asmRef) => + string.Equals(asmRef.Name, SqlServerTypesAssemblyName, StringComparison.OrdinalIgnoreCase); + + /// + /// Decides whether the driver may load the assembly named by + /// and, when it may, produces the assembly. + /// + /// The decision and the load are deliberately performed by a single call. + /// Deciding separately from loading was unsafe: on .NET the loader ignores + /// the public key token in an , so a caller that + /// consulted the policy and then called + /// itself could still be handed a same-named assembly with the wrong + /// identity. Pinning the reference is therefore not an enforcement + /// boundary; verifying what actually came back is, and doing both here + /// means no caller can forget. This mirrors what + /// SqlAuthenticationProviderManager does for the Azure extension + /// assembly. + /// + /// + /// The server-supplied assembly reference. It is normalized in place when + /// it names the built-in SQL Server CLR types assembly. + /// + /// + /// The type system assembly version negotiated for the connection, used to + /// pin the version of the built-in SQL Server CLR types assembly. Null + /// when no connection context is available, in which case the version is + /// left to the loader and only the culture and public key token are pinned. + /// + /// + /// The assembly the caller must use, or null when the policy refused. + /// + /// True when the assembly may be used. + internal static bool TryLoad( + AssemblyName asmRef, + Version? typeSystemAssemblyVersion, + out Assembly? assembly) + { + assembly = null; + + if (LegacyBehaviorEnabled) + { + assembly = Assembly.Load(asmRef); + return true; + } + + if (!TryDecide(asmRef, typeSystemAssemblyVersion, out Decision decision)) + { + return false; + } + + // The process already holds this assembly, so there is nothing to load + // and nothing to verify: the instance is the one the application itself + // brought in, whatever identity the server claimed for it. + if (decision.Loaded is not null) + { + assembly = decision.Loaded; + return true; + } + + Assembly loaded = LoadWithoutTrustingDependencies(asmRef); + + if (loaded is null) + { + return false; + } + + if (!SatisfiesRequiredIdentity(loaded, decision)) + { + SqlClientEventSource.Log.TryTraceEvent( + "UdtAssemblyPolicy.TryLoad | ERR | Assembly '{0}' was loaded but has an unexpected identity '{1}' and will not be used.", + asmRef.Name, + loaded.FullName); + + return false; + } + + assembly = loaded; + + return true; + } + + /// + /// The outcome of evaluating the policy: whether the reference is permitted + /// and, if so, what must be true of the assembly that the loader returns. + /// + private readonly struct Decision + { + /// + /// The assembly the process already holds, when the reference was + /// permitted on that basis. Null when the caller must load it. + /// + internal Assembly? Loaded { get; init; } + + /// + /// The simple name the loaded assembly must carry. + /// + /// + /// Every basis for permitting a load rests on the simple name: it is + /// what the allow list was matched on and what the built-in exemption + /// recognizes. A custom AssemblyResolve handler or + /// AssemblyLoadContext resolver can return an assembly with an + /// entirely different name, which would inherit a permission that was + /// never granted to it, so the name is confirmed after the load like + /// every other component the decision relied on. + /// + internal string? RequiredSimpleName { get; init; } + + /// + /// The public key token the loaded assembly must carry, or null when + /// the basis for permitting it placed no constraint on the token. + /// + internal byte[]? RequiredPublicKeyToken { get; init; } + + /// + /// True when the loaded assembly must carry no public key token at all, + /// because the allow list entry explicitly said PublicKeyToken=null. + /// + internal bool RequireUnsigned { get; init; } + + /// + /// The version the loaded assembly must carry, or null when the basis + /// for permitting it placed no constraint on the version. + /// + /// + /// A binding redirect on .NET Framework, or a custom resolver on .NET, + /// can return a different version than the one requested, so a version + /// the policy relied on has to be confirmed after the load rather than + /// assumed from the reference. + /// + internal Version? RequiredVersion { get; init; } + + /// + /// The culture name the loaded assembly must carry, or null when the + /// basis for permitting it placed no constraint on the culture. The + /// empty string means the neutral culture. + /// + internal string? RequiredCultureName { get; init; } + } + + /// + /// Loads an assembly the policy has permitted, without letting the + /// dependencies that load alongside it inherit that permission. + /// + /// + /// The handler cannot otherwise tell a + /// dependency pulled in by this call from an assembly the application + /// loaded itself, and recording the former would quietly grant the + /// already-loaded permission to the whole reference closure. The guard is + /// per thread and counted, so a nested load (a resolver that loads + /// something in order to satisfy this one) stays covered. + /// + private static Assembly LoadWithoutTrustingDependencies(AssemblyName asmRef) + { + t_insidePolicyLoad++; + + try + { + return Assembly.Load(asmRef); + } + finally + { + t_insidePolicyLoad--; + } + } + + /// + /// Evaluates the policy without loading anything. This is the decision + /// half of , exposed so that tests can observe the + /// decision for assembly names that do not exist on disk. Production code + /// must use , because a decision alone does not + /// enforce the identity of what the loader returns. + /// + /// + /// The instance the process already holds, when that was the basis for + /// permitting the reference; otherwise null. + /// + /// The server-supplied assembly reference. + /// + /// The type system assembly version negotiated for the connection, or null. + /// + internal static bool IsPermitted( + AssemblyName asmRef, + Version? typeSystemAssemblyVersion, + out Assembly? alreadyLoaded) + { + if (LegacyBehaviorEnabled) + { + alreadyLoaded = null; + + return true; + } + + bool permitted = TryDecide(asmRef, typeSystemAssemblyVersion, out Decision decision); + + alreadyLoaded = decision.Loaded; + + return permitted; + } + + /// + /// Evaluates the policy without loading anything. + /// + private static bool TryDecide( + AssemblyName asmRef, + Version? typeSystemAssemblyVersion, + out Decision decision) + { + decision = default; + + string? simpleName = asmRef.Name; + if (string.IsNullOrEmpty(simpleName)) + { + return false; + } + + // Snapshot what the process already holds before any tier below can + // trigger a load. The already-loaded tier is meant to reflect only + // what the application brought in of its own accord, so if the very + // first request were permitted by the allow list, the dependencies + // that arrived with it would otherwise be captured by a later, lazier + // snapshot and silently inherit that permission. Taking the snapshot + // here also attaches the load handler, so every subsequent load is + // attributed rather than absorbed. + lock (s_lock) + { + GetLoadedAssemblies(); + } + + // The built-in types assembly is always permitted, but only once its + // identity has been pinned, so the exemption cannot be satisfied by an + // arbitrary assembly that borrows the name. + if (IsSqlServerTypesAssembly(asmRef)) + { + PinSqlServerTypesIdentity(asmRef, typeSystemAssemblyVersion); + + decision = new Decision + { + RequiredSimpleName = asmRef.Name, + RequiredPublicKeyToken = s_sqlServerTypesPublicKeyToken, + // The culture was just pinned to neutral, so require that back. + RequiredCultureName = string.Empty, + // The version is only constrained when the connection supplied + // one; otherwise the loader is free to pick. + RequiredVersion = typeSystemAssemblyVersion, + }; + + return true; + } + + // The allow list is the application stating which assemblies it is + // willing to have loaded on a server's say-so, so the reference is + // handed to the loader as given. Whatever identity the matched entry + // specified is carried forward and enforced against the result. + if (TryMatchAllowList(asmRef, out AssemblyName? matched)) + { + byte[]? allowedToken = matched!.GetPublicKeyToken(); + + decision = new Decision + { + // The entry was matched on this name, so this is the name the + // permission was granted to. + RequiredSimpleName = simpleName, + // A null token means the entry did not mention one, an empty + // token means it explicitly required an unsigned assembly. + RequiredPublicKeyToken = allowedToken is { Length: > 0 } ? allowedToken : null, + RequireUnsigned = allowedToken is { Length: 0 }, + // Only components the entry actually specified are enforced, so + // that a simple-name entry stays as permissive after the load as + // it was during matching. + RequiredVersion = matched.Version, + RequiredCultureName = matched.CultureName, + }; + + return true; + } + + // Otherwise the only remaining basis is that the process already holds + // an assembly by this simple name, in which case that instance is used + // and the server-supplied identity is discarded. + if (TryGetLoadedAssembly(simpleName!, out Assembly? loaded)) + { + decision = new Decision { Loaded = loaded }; + + return true; + } + + return false; + } + + /// + /// Verifies that an assembly the loader returned actually carries the + /// identity that the policy required of it. + /// + private static bool SatisfiesRequiredIdentity(Assembly loaded, Decision decision) + { + AssemblyName actual; + + try + { + actual = loaded.GetName(); + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + // If the identity cannot be read it cannot be confirmed, so the + // assembly is refused. + return false; + } + + // The simple name is the one component every basis constrains, so it is + // always confirmed. Without this a resolver could answer the request + // with an unrelated assembly and have it inherit the permission. + if (decision.RequiredSimpleName is not null && + !string.Equals( + decision.RequiredSimpleName, + actual.Name, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + byte[]? actualToken = actual.GetPublicKeyToken(); + + if (decision.RequireUnsigned) + { + if (actualToken is { Length: > 0 }) + { + return false; + } + } + else if (decision.RequiredPublicKeyToken is not null && + (actualToken is null || + !actualToken.AsSpan().SequenceEqual(decision.RequiredPublicKeyToken.AsSpan()))) + { + return false; + } + + // A binding redirect or a custom resolver can satisfy the request with a + // different version or culture than the one asked for, so any component + // the decision relied on is confirmed against what actually arrived. + if (decision.RequiredVersion is not null && + !decision.RequiredVersion.Equals(actual.Version)) + { + return false; + } + + if (decision.RequiredCultureName is not null && + !string.Equals( + decision.RequiredCultureName, + actual.CultureName ?? string.Empty, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + /// + /// Pins the identity of the built-in SQL Server CLR types assembly. + /// + /// The version is normalized to the type system version negotiated for the + /// connection, which is long-standing behavior: the server advertises the + /// version it holds, and the client instantiates the version it has. + /// + /// The public key token is normalized to the token that Microsoft signs the + /// assembly with. Without this, a server that omits the token (or supplies + /// a different one) would cause a partial-name bind that an unsigned + /// same-named assembly on the probing path could satisfy. + /// + /// The culture is normalized to neutral. The shipped assembly is culture + /// neutral, so leaving the culture server-controlled would let a reference + /// carrying Culture=xx-YY steer the bind towards a satellite-shaped + /// name that the real assembly never uses. + /// + /// Normalizing the reference is necessary but not sufficient, because on + /// .NET the loader ignores the requested token. + /// verifies the identity of whatever the loader actually returns. + /// + /// The assembly reference to normalize, in place. + /// + /// The type system assembly version negotiated for the connection, or null + /// to leave the version unconstrained. + /// + private static void PinSqlServerTypesIdentity(AssemblyName asmRef, Version? typeSystemAssemblyVersion) + { + if (typeSystemAssemblyVersion is not null) + { + asmRef.Version = typeSystemAssemblyVersion; + } + + asmRef.CultureInfo = CultureInfo.InvariantCulture; + asmRef.SetPublicKeyToken((byte[])s_sqlServerTypesPublicKeyToken.Clone()); + } + + /// + /// Discards all cached state. Intended for use by tests, which need to + /// observe the effect of changing the allow list or the policy switches. + /// + internal static void ResetCache() + { + lock (s_lock) + { + s_allowList = null; + s_allowListSource = null; + s_loadedAssemblies = null; + } + } + + #endregion + + #region Helpers + + /// + /// Finds the allow list entry that satisfies, if + /// any. The matched entry is returned so that the identity it specified + /// can be enforced against the assembly the loader returns. + /// + private static bool TryMatchAllowList(AssemblyName asmRef, out AssemblyName? matched) + { + List allowList = GetAllowList(); + + for (int i = 0; i < allowList.Count; i++) + { + if (Matches(allowList[i], asmRef)) + { + matched = allowList[i]; + + return true; + } + } + + matched = null; + + return false; + } + + /// + /// Determines whether a server-supplied assembly reference satisfies an + /// allow list entry. Only the components that the entry actually specifies + /// are compared, so a simple-name entry permits any version, culture, and + /// public key token. + /// + private static bool Matches(AssemblyName allowed, AssemblyName candidate) + { + if (!string.Equals(allowed.Name, candidate.Name, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (allowed.Version is not null && !allowed.Version.Equals(candidate.Version)) + { + return false; + } + + // AssemblyName.CultureName is the empty string for the neutral culture + // and null when the entry did not specify a culture at all. + if (allowed.CultureName is not null && + !string.Equals(allowed.CultureName, candidate.CultureName ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // AssemblyName distinguishes an omitted public key token (null) from an + // explicitly unsigned one (PublicKeyToken=null, which parses to an empty + // array). Treating the latter as "unconstrained" would let an entry + // that deliberately named an unsigned assembly be satisfied by a signed + // one, so the two cases are kept apart. + byte[]? allowedToken = allowed.GetPublicKeyToken(); + + if (allowedToken is not null) + { + byte[]? candidateToken = candidate.GetPublicKeyToken(); + + if (allowedToken.Length == 0) + { + // The entry requires an unsigned assembly. + return candidateToken is null || candidateToken.Length == 0; + } + + if (candidateToken is null || + !candidateToken.AsSpan().SequenceEqual(allowedToken.AsSpan())) + { + return false; + } + } + + return true; + } + + /// + /// Returns the parsed allow list, re-parsing it if the application has + /// changed the underlying AppContext data since it was last read. + /// + private static List GetAllowList() + { + // AppDomain.GetData rather than AppContext.GetData: the latter does not + // exist on .NET Framework, while the former is implemented over the same + // AppContext data on .NET, so it reads both AppContext.SetData values and + // runtimeconfig.json configProperties on every target framework. + string source = + AppDomain.CurrentDomain.GetData(AllowListAppContextDataName) as string ?? string.Empty; + + lock (s_lock) + { + if (s_allowList is not null && string.Equals(s_allowListSource, source, StringComparison.Ordinal)) + { + return s_allowList; + } + + List parsed = new(); + + foreach (string entry in source.Split(';')) + { + string trimmed = entry.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + try + { + AssemblyName name = new(trimmed); + if (!string.IsNullOrEmpty(name.Name)) + { + parsed.Add(name); + } + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + // A malformed entry must not take down the application, and + // it must not silently widen the policy either, so it is + // traced and skipped. + SqlClientEventSource.Log.TryTraceEvent( + "UdtAssemblyPolicy.GetAllowList | ERR | Ignoring malformed UDT assembly allow list entry '{0}'.", + trimmed); + } + } + + s_allowList = parsed; + s_allowListSource = source; + + return parsed; + } + } + + /// + /// Looks up an assembly that the process has already loaded under the given + /// simple name. + /// + private static bool TryGetLoadedAssembly(string simpleName, out Assembly? assembly) + { + lock (s_lock) + { + Dictionary> loaded = GetLoadedAssemblies(); + + if (loaded.TryGetValue(simpleName, out WeakReference? reference) && + reference.TryGetTarget(out Assembly? target)) + { + assembly = target; + + return true; + } + + // The assembly has been collected, which means its load context was + // unloaded. Drop the entry so the name is no longer permitted on + // the strength of a load that no longer exists. + if (reference is not null) + { + loaded.Remove(simpleName); + } + + assembly = null; + + return false; + } + } + + /// + /// Returns the map of loaded assembly simple names to instances, building it + /// on first use and thereafter relying on the + /// handler to keep it current. + /// + /// + /// Callers must hold . + /// + private static Dictionary> GetLoadedAssemblies() + { + EnsureAssemblyLoadHandlerAttached(); + + if (s_loadedAssemblies is not null) + { + return s_loadedAssemblies; + } + + Dictionary> loaded = new(StringComparer.OrdinalIgnoreCase); + + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Remember(loaded, assembly); + } + + s_loadedAssemblies = loaded; + + return loaded; + } + + /// + /// Records under its simple name, keeping the + /// first assembly seen for a given name. + /// + private static void Remember(Dictionary> loaded, Assembly assembly) + { + if (assembly.IsDynamic) + { + // A dynamic assembly cannot be the target of Assembly.Load by name, + // and asking one for its name can throw. + return; + } + + if (!IsInDriverLoadContext(assembly)) + { + // Only assemblies in the load context that Assembly.Load would + // resolve into can satisfy this tier. Recording one from another + // context would permit a reference that the loader would then + // resolve to a different assembly, or to none at all. + return; + } + + try + { + string? name = assembly.GetName().Name; + + if (!string.IsNullOrEmpty(name) && + (!loaded.TryGetValue(name!, out WeakReference? existing) || + !existing.TryGetTarget(out _))) + { + loaded[name!] = new WeakReference(assembly); + } + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + // Reading the name can fail for assemblies loaded from a byte array + // or produced by a trimmer. Losing one only makes the policy + // stricter. + SqlClientEventSource.Log.TryTraceEvent( + "UdtAssemblyPolicy.Remember | INFO | Unable to read the name of a loaded assembly."); + } + } + + /// + /// Determines whether an assembly lives in the load context that the + /// driver's own calls resolve + /// into. + /// + /// + /// On .NET, resolves against the + /// load context of the calling assembly, which is the driver's. Assemblies + /// held by other contexts are therefore not reachable by name from here, + /// and an application that loads its UDT assembly into a separate + /// collectible context must name it on the allow list. + /// + private static bool IsInDriverLoadContext(Assembly assembly) + { + #if NET + return AssemblyLoadContext.GetLoadContext(assembly) == s_driverLoadContext; + #else + // .NET Framework has a single load context per AppDomain for this + // purpose, so every loaded assembly qualifies. + return true; + #endif + } + + /// + /// Attaches the assembly load handler that keeps the cached map of loaded + /// assemblies current, if it has not been attached already. + /// + /// + /// Callers must hold . + /// + private static void EnsureAssemblyLoadHandlerAttached() + { + if (s_assemblyLoadHandlerAttached) + { + return; + } + + AppDomain.CurrentDomain.AssemblyLoad += static (_, args) => + { + // A load the policy itself triggered brings in the permitted + // assembly's dependencies. Those must not enter the already-loaded + // tier, or permission would spread along the reference closure. + if (t_insidePolicyLoad > 0) + { + return; + } + + lock (s_lock) + { + // Nothing to update if the map has not been built yet; it will + // pick the assembly up when it is. + if (s_loadedAssemblies is not null) + { + Remember(s_loadedAssemblies, args.LoadedAssembly); + } + } + }; + + s_assemblyLoadHandlerAttached = true; + } + + #endregion +} diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index c8f18d38bc..d2da200082 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -5127,6 +5127,24 @@ internal static string SQLUDT_Unexpected { } } + /// + /// Looks up a localized string similar to The assembly '{0}', named by a user-defined type returned by the server, was not loaded because it is not permitted by the user-defined type assembly load policy. To permit it, add the assembly name to the 'Microsoft.Data.SqlClient.UdtAssemblyAllowList' AppContext data element.. + /// + internal static string SQLUDT_AssemblyNotAllowed { + get { + return ResourceManager.GetString("SQLUDT_AssemblyNotAllowed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The type '{0}', named by a user-defined type returned by the server, is not a user-defined type because it is not annotated with SqlUserDefinedTypeAttribute.. + /// + internal static string SQLUDT_TypeNotUserDefined { + get { + return ResourceManager.GetString("SQLUDT_TypeNotUserDefined", resourceCulture); + } + } + /// /// Looks up a localized string similar to UdtTypeName property must be set only for UDT parameters.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index 57cbf80016..3ca8366958 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -1122,6 +1122,12 @@ unexpected error encountered in SqlClient data provider. {0} + + The assembly '{0}', named by a user-defined type returned by the server, was not loaded because it is not permitted by the user-defined type assembly load policy. To permit it, add the assembly name to the 'Microsoft.Data.SqlClient.UdtAssemblyAllowList' AppContext data element. + + + The type '{0}', named by a user-defined type returned by the server, is not a user-defined type because it is not annotated with SqlUserDefinedTypeAttribute. + UdtTypeName property must be set for UDT parameters. diff --git a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs index 49ad2712ec..e67d39415d 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs @@ -59,6 +59,7 @@ public sealed class LocalAppContextSwitchesHelper : IDisposable private readonly bool? _useConnectionPoolV2Original; private readonly bool? _useLegacyIdleTimeoutBehaviorOriginal; private readonly bool? _useOverallConnectTimeoutForPoolWaitOriginal; + private readonly bool? _useLegacyUdtAssemblyLoadOriginal; #if NET // The s_useManagedNetworking field only exists in the SqlClient assembly // when it is built for .NET on Windows, so it is captured/restored at @@ -127,6 +128,8 @@ public LocalAppContextSwitchesHelper() GetSwitchValue("s_useLegacyIdleTimeoutBehavior"); _useOverallConnectTimeoutForPoolWaitOriginal = GetSwitchValue("s_useOverallConnectTimeoutForPoolWait"); + _useLegacyUdtAssemblyLoadOriginal = + GetSwitchValue("s_useLegacyUdtAssemblyLoad"); #if NET if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -203,6 +206,9 @@ public void Dispose() SetSwitchValue( "s_useOverallConnectTimeoutForPoolWait", _useOverallConnectTimeoutForPoolWaitOriginal); + SetSwitchValue( + "s_useLegacyUdtAssemblyLoad", + _useLegacyUdtAssemblyLoadOriginal); #if NET if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -370,6 +376,15 @@ public bool? UseOverallConnectTimeoutForPoolWait set => SetSwitchValue("s_useOverallConnectTimeoutForPoolWait", value); } + /// + /// Get or set the UseLegacyUdtAssemblyLoad switch value. + /// + public bool? UseLegacyUdtAssemblyLoad + { + get => GetSwitchPropertyValue(nameof(UseLegacyUdtAssemblyLoad)); + set => SetSwitchValue("s_useLegacyUdtAssemblyLoad", value); + } + #if NET /// /// Get or set the UseManagedNetworking switch value. diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs index ff70c17f4b..72c72403f3 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs @@ -44,6 +44,7 @@ public void TestDefaultAppContextSwitchValues() switchesHelper.UseConnectionPoolV2 = null; switchesHelper.UseLegacyIdleTimeoutBehavior = null; switchesHelper.UseMinimumLoginTimeout = null; + switchesHelper.UseLegacyUdtAssemblyLoad = null; #if NET switchesHelper.GlobalizationInvariantMode = null; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -69,6 +70,7 @@ public void TestDefaultAppContextSwitchValues() Assert.False(switchesHelper.IgnoreServerProvidedFailoverPartner); Assert.False(switchesHelper.UseLegacyFailoverAlternationOnLoginSqlErrors); Assert.False(switchesHelper.EnableMultiSubnetFailoverByDefault); + Assert.False(switchesHelper.UseLegacyUdtAssemblyLoad); #if NET Assert.False(switchesHelper.GlobalizationInvariantMode); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyLoadHardeningTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyLoadHardeningTest.cs new file mode 100644 index 0000000000..a2c429edf1 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyLoadHardeningTest.cs @@ -0,0 +1,492 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlTypes; +using System.Reflection; +using Microsoft.Data.SqlClient.Server; +using Microsoft.Data.SqlClient.Tests.Common; +using Microsoft.SqlServer.Server; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// +/// Provides regression tests for the UDT assembly load hardening, driving +/// directly with the kind of +/// assembly-qualified name a hostile or compromised server could return. +/// +/// +/// Before the fix, handed any server-supplied +/// assembly name straight to , and then +/// invoked a static member on the resolved type without checking that it was a +/// user-defined type at all. +/// +/// The two steps are not equally dangerous, and the tests below are written to +/// reflect that. On CoreCLR the load itself runs nothing from the target +/// assembly: neither , nor resolving a +/// type from it, nor reading that type's custom attributes executes any of its +/// code. What the load grants is the ability to bring a server-chosen file into +/// the process. Code runs at the later static member invocation, which is what +/// pulls in the module initializer and the type's static constructor. +/// +/// So these tests assert two distinct things: that a denied assembly is never +/// loaded at all, and that a type which is not annotated as a user-defined type +/// never reaches the invocation that would run its code. +/// +[Collection(AppContextSwitchTestCollection.Name)] +public class UdtAssemblyLoadHardeningTest +{ + /// + /// A connection string that is never opened. Only the parsed connection + /// options are needed, so that the type system assembly version the policy + /// pins against is available. + /// + private const string ConnectionString = "Data Source=localhost;Integrated Security=true"; + + /// + /// The assembly-qualified name of a type in an assembly that is neither + /// loaded into the test process nor referenced by anything that is. + /// + private const string HostileAssemblyQualifiedName = + "Contoso.Evil.Payload, Contoso.Evil, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; + + #region Assembly load policy + + /// + /// Verifies that resolving a UDT whose assembly is not permitted never + /// reaches the assembly loader, and reports a policy failure rather than + /// silently succeeding. + /// + /// + /// The assertion on the exception matters as much as the one on the + /// recorder. Because the hostile assembly does not exist on disk, a driver + /// with no policy at all would also fail to load it and would also raise no + /// AssemblyLoad event, so "nothing was loaded" alone would pass against the + /// vulnerable implementation too. Requiring the specific policy denial + /// distinguishes "the policy refused to ask" from "the loader looked and did + /// not find it". + /// + [Fact] + public void CheckGetExtendedUDTInfo_UnknownAssembly_IsNeverLoaded() + { + using PolicyScope scope = new(); + using AssemblyLoadRecorder recorder = new(); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData(HostileAssemblyQualifiedName); + + Exception exception = Record.Exception( + () => connection.CheckGetExtendedUDTInfo(metaData, fThrow: true)); + + Assert.NotNull(exception); + Assert.Null(metaData.udt.Type); + Assert.DoesNotContain("Contoso.Evil", recorder.LoadedNames); + + // The failure must be the policy's denial, not a loader miss. + Assert.IsType(exception); + Assert.Contains("Contoso.Evil", exception!.Message); + Assert.Contains(UdtAssemblyPolicy.AllowListAppContextDataName, exception.Message); + } + + /// + /// Verifies that a permitted UDT still resolves, so that the denial tests + /// above are not passing simply because resolution never works. + /// + /// + /// This is the positive control for the test above. Without it, a change + /// that broke UDT resolution outright would leave every "was refused" + /// assertion passing for the wrong reason. + /// + [Fact] + public void CheckGetExtendedUDTInfo_LoadedUserDefinedType_Resolves() + { + AssemblyName self = typeof(UdtAssemblyLoadHardeningTest).Assembly.GetName(); + string qualifiedName = $"{typeof(AUserDefinedType).FullName}, {self.Name}"; + + using PolicyScope scope = new(); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData(qualifiedName); + + connection.CheckGetExtendedUDTInfo(metaData, fThrow: true); + + Assert.Equal(typeof(AUserDefinedType), metaData.udt.Type); + } + + /// + /// Verifies that the non-throwing call sites (for example + /// SqlDataReader.GetFieldType) still tolerate a denied assembly, leaving the + /// resolved type null instead of faulting the read. + /// + [Fact] + public void CheckGetExtendedUDTInfo_UnknownAssembly_DoesNotThrowWhenNotRequested() + { + using PolicyScope scope = new(); + using AssemblyLoadRecorder recorder = new(); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData(HostileAssemblyQualifiedName); + + connection.CheckGetExtendedUDTInfo(metaData, fThrow: false); + + Assert.Null(metaData.udt.Type); + Assert.DoesNotContain("Contoso.Evil", recorder.LoadedNames); + } + + /// + /// Verifies that the legacy switch restores the pre-fix behavior, so an + /// application that depends on it has a documented escape hatch. The load + /// is still expected to fail, because the assembly does not exist, but it + /// must fail in the loader rather than in the policy. + /// + [Fact] + public void CheckGetExtendedUDTInfo_LegacyMode_ReachesTheLoader() + { + using PolicyScope scope = new(legacy: true); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData(HostileAssemblyQualifiedName); + + Exception exception = Record.Exception( + () => connection.CheckGetExtendedUDTInfo(metaData, fThrow: true)); + + // The loader, not the policy, is what refuses the load in legacy mode. + Assert.IsAssignableFrom(exception); + } + + #endregion + + #region User-defined type validation + + /// + /// Verifies that a type which resolves successfully but is not annotated + /// with SqlUserDefinedTypeAttribute is rejected before any of its code can + /// run. + /// + /// + /// This is the second half of the vulnerability: GetUdtValue's null branch + /// calls InvokeMember("Null", ... Static ...) on the resolved type, which + /// runs its static constructor. Rejecting the type in + /// CheckGetExtendedUDTInfo is the last point at which the driver can decline + /// without executing anything, because reading custom attributes does not + /// trigger a static constructor. + /// + [Fact] + public void CheckGetExtendedUDTInfo_TypeWithoutUdtAttribute_IsRejected() + { + using PolicyScope scope = new(); + + // This test assembly is loaded, so the assembly load policy permits it + // in the default Restricted mode; only the attribute check stands + // between the server-supplied name and the type's code. + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData( + typeof(NotAUserDefinedType).AssemblyQualifiedName!); + + Exception exception = Record.Exception( + () => connection.CheckGetExtendedUDTInfo(metaData, fThrow: true)); + + Assert.NotNull(exception); + Assert.Null(metaData.udt.Type); + Assert.False( + StaticConstructorMarker.Ran, + "The type's static constructor must not have been triggered."); + } + + /// + /// Verifies that a legitimate user-defined type in a permitted assembly is + /// still resolved, so the hardening does not break the supported scenario. + /// + [Fact] + public void CheckGetExtendedUDTInfo_UserDefinedType_IsResolved() + { + using PolicyScope scope = new(); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData( + typeof(AUserDefinedType).AssemblyQualifiedName!); + + connection.CheckGetExtendedUDTInfo(metaData, fThrow: true); + + Assert.Equal(typeof(AUserDefinedType), metaData.udt.Type); + } + + /// + /// Verifies that legacy mode also bypasses the user-defined type check, so + /// the switch fully restores the previous behavior. + /// + [Fact] + public void CheckGetExtendedUDTInfo_LegacyMode_SkipsUdtAttributeCheck() + { + using PolicyScope scope = new(legacy: true); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData( + typeof(NotAUserDefinedType).AssemblyQualifiedName!); + + connection.CheckGetExtendedUDTInfo(metaData, fThrow: true); + + Assert.Equal(typeof(NotAUserDefinedType), metaData.udt.Type); + } + + /// + /// Verifies that a type name carrying no assembly part is still rejected. + /// + /// + /// Type.GetType resolves a bare type name against the core library without + /// ever consulting the assembly resolver, so the assembly load policy is + /// structurally bypassed for such a name. The SqlUserDefinedTypeAttribute + /// check is the only gate that stands in its way, and this test locks that + /// in: a server that sends "System.String" must not end up with the driver + /// invoking members on System.String. + /// + [Theory] + [InlineData("System.String")] + [InlineData("System.Diagnostics.Process")] + public void CheckGetExtendedUDTInfo_TypeNameWithoutAssembly_IsRejected(string typeName) + { + using PolicyScope scope = new(); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData(typeName); + + Exception exception = Record.Exception( + () => connection.CheckGetExtendedUDTInfo(metaData, fThrow: true)); + + Assert.NotNull(exception); + Assert.Null(metaData.udt.Type); + } + + /// + /// Verifies that a bare type name is rejected without throwing at the call + /// sites that ask not to throw, which is how GetFieldType probes UDT + /// metadata. + /// + [Fact] + public void CheckGetExtendedUDTInfo_TypeNameWithoutAssembly_DoesNotThrowWhenNotRequested() + { + using PolicyScope scope = new(); + + SqlConnection connection = new(ConnectionString); + SqlMetaDataPriv metaData = CreateUdtMetaData("System.String"); + + connection.CheckGetExtendedUDTInfo(metaData, fThrow: false); + + Assert.Null(metaData.udt.Type); + } + + #endregion + + #region SMI metadata path + + /// + /// Verifies that the SMI resolution path refuses an assembly the policy does + /// not permit. + /// + /// + /// SmiMetaData.Type is a second live resolution site for + /// server-supplied names, reached through table-valued parameters and + /// SqlDataReader.GetInternalSmiMetaData. It is covered separately + /// from CheckGetExtendedUDTInfo because a regression in one would not + /// be caught by tests of the other. + /// + [Fact] + public void SmiMetaData_UnknownAssembly_IsRefused() + { + using PolicyScope scope = new(); + using AssemblyLoadRecorder recorder = new(); + + SmiMetaData metaData = CreateSmiUdtMetaData(HostileAssemblyQualifiedName); + + Exception exception = Record.Exception(() => _ = metaData.Type); + + Assert.IsType(exception); + Assert.Contains("Contoso.Evil", exception!.Message); + Assert.DoesNotContain("Contoso.Evil", recorder.LoadedNames); + } + + /// + /// Verifies that the SMI path applies the user-defined type gate to a name + /// that carries no assembly part. + /// + /// + /// A bare name never reaches the assembly resolver, so the attribute check + /// is the only thing standing between a server-chosen type and + /// ValueUtilsSmi.NullUdtInstance, which invokes the type's static + /// Null member. + /// + [Fact] + public void SmiMetaData_BareNonUserDefinedTypeName_IsRefused() + { + using PolicyScope scope = new(); + + SmiMetaData metaData = CreateSmiUdtMetaData(typeof(string).FullName!); + + Exception exception = Record.Exception(() => _ = metaData.Type); + + Assert.IsType(exception); + } + + /// + /// Verifies that the SMI path still resolves a genuine user-defined type, so + /// the refusals above are not simply the path being broken. + /// + [Fact] + public void SmiMetaData_LoadedUserDefinedType_Resolves() + { + AssemblyName self = typeof(UdtAssemblyLoadHardeningTest).Assembly.GetName(); + + using PolicyScope scope = new(); + + SmiMetaData metaData = CreateSmiUdtMetaData( + $"{typeof(AUserDefinedType).FullName}, {self.Name}"); + + Assert.Equal(typeof(AUserDefinedType), metaData.Type); + } + + #endregion + + #region Helpers + + /// + /// Builds SMI metadata for a UDT column whose CLR type is described only by + /// a server-supplied assembly-qualified name, so that reading + /// SmiMetaData.Type exercises the fault-in resolution path. + /// + private static SmiMetaData CreateSmiUdtMetaData(string assemblyQualifiedName) => + new( + dbType: SqlDbType.Udt, + maxLength: SmiMetaData.UnlimitedMaxLengthIndicator, + precision: 0, + scale: 0, + localeId: 0, + compareOptions: SqlCompareOptions.None, + userDefinedType: null, + udtAssemblyQualifiedName: assemblyQualifiedName, + isMultiValued: false, + fieldTypes: null, + extendedProperties: null); + + private static SqlMetaDataPriv CreateUdtMetaData(string assemblyQualifiedName) => + new() + { + udt = new SqlMetaDataUdt + { + DatabaseName = "db", + SchemaName = "dbo", + TypeName = "udt", + AssemblyQualifiedName = assemblyQualifiedName, + }, + }; + + /// + /// Forces the policy switches to known values and clears the allow list and + /// every policy cache for the duration of a test. + /// + private sealed class PolicyScope : IDisposable + { + private readonly LocalAppContextSwitchesHelper _switches; + private readonly object? _originalAllowList; + + public PolicyScope(bool legacy = false) + { + _switches = new LocalAppContextSwitchesHelper(); + _originalAllowList = + AppDomain.CurrentDomain.GetData(UdtAssemblyPolicy.AllowListAppContextDataName); + + _switches.UseLegacyUdtAssemblyLoad = legacy; + + AppDomain.CurrentDomain.SetData( + UdtAssemblyPolicy.AllowListAppContextDataName, + null); + UdtAssemblyPolicy.ResetCache(); + } + + public void Dispose() + { + AppDomain.CurrentDomain.SetData( + UdtAssemblyPolicy.AllowListAppContextDataName, + _originalAllowList); + UdtAssemblyPolicy.ResetCache(); + _switches.Dispose(); + } + } + + /// + /// Records the simple name of every assembly loaded into the process while + /// it is alive, so a test can assert that a load never happened. + /// + private sealed class AssemblyLoadRecorder : IDisposable + { + private readonly List _loadedNames = new(); + + public AssemblyLoadRecorder() + { + AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad; + } + + public IReadOnlyList LoadedNames + { + get + { + lock (_loadedNames) + { + return _loadedNames.ToArray(); + } + } + } + + public void Dispose() => + AppDomain.CurrentDomain.AssemblyLoad -= OnAssemblyLoad; + + private void OnAssemblyLoad(object? sender, AssemblyLoadEventArgs args) + { + string? name = args.LoadedAssembly.GetName().Name; + if (name is not null) + { + lock (_loadedNames) + { + _loadedNames.Add(name); + } + } + } + } + + /// + /// A type that a hostile server could name but that is not a user-defined + /// type. Its static constructor records that it ran so a test can prove it + /// did not. + /// + private sealed class NotAUserDefinedType + { + static NotAUserDefinedType() + { + StaticConstructorMarker.Ran = true; + } + } + + /// + /// Holds the flag that 's static + /// constructor sets. It lives in a separate class so that reading it does + /// not itself trigger the constructor under test. + /// + private static class StaticConstructorMarker + { + internal static bool Ran; + } + + /// + /// A well-formed user-defined type, used to prove the hardening does not + /// reject legitimate types. + /// + [SqlUserDefinedType(Format.UserDefined, MaxByteSize = 8)] + private sealed class AUserDefinedType + { + } + + #endregion +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyPolicyTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyPolicyTest.cs new file mode 100644 index 0000000000..fa167357c9 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyPolicyTest.cs @@ -0,0 +1,793 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Data.SqlClient.Tests.Common; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// +/// Provides unit tests for , the deny-by-default +/// policy that governs which assemblies the driver is willing to load while +/// resolving a server-supplied UDT assembly-qualified name. +/// +[Collection(AppContextSwitchTestCollection.Name)] +public class UdtAssemblyPolicyTest +{ + /// + /// The public key token that Microsoft signs Microsoft.SqlServer.Types with. + /// + private const string SqlServerTypesPublicKeyToken = "89845dcd8080cc91"; + + /// + /// An assembly name that is neither loaded into the test process nor + /// referenced by anything that is. + /// + private const string UnknownAssemblyName = "Contoso.Totally.Unknown.Assembly"; + + /// + /// Asks the policy for a decision, discarding the resolved assembly. Most + /// tests care only whether the reference was permitted. + /// + private static bool IsAllowed(AssemblyName asmRef, Version? typeSystemAssemblyVersion) => + UdtAssemblyPolicy.IsPermitted(asmRef, typeSystemAssemblyVersion, out _); + + #region Scope + + /// + /// Acquires the app context switch lock, forces the policy switches to + /// known values, and clears the allow list and every policy cache. Disposal + /// restores the original switch values and allow list, and clears the caches + /// again so no state leaks into the next test. + /// + private sealed class PolicyScope : IDisposable + { + private readonly LocalAppContextSwitchesHelper _switches; + private readonly object? _originalAllowList; + + public PolicyScope(bool legacy = false) + { + _switches = new LocalAppContextSwitchesHelper(); + _originalAllowList = + AppDomain.CurrentDomain.GetData(UdtAssemblyPolicy.AllowListAppContextDataName); + + _switches.UseLegacyUdtAssemblyLoad = legacy; + + SetAllowList(null); + } + + /// + /// Enters an enforcing scope with the given allow list already applied. + /// + public PolicyScope(string? allowList) + : this(legacy: false) + { + SetAllowList(allowList); + } + + public static void SetAllowList(string? value) + { + AppDomain.CurrentDomain.SetData( + UdtAssemblyPolicy.AllowListAppContextDataName, + value); + UdtAssemblyPolicy.ResetCache(); + } + + public void Dispose() + { + AppDomain.CurrentDomain.SetData( + UdtAssemblyPolicy.AllowListAppContextDataName, + _originalAllowList); + UdtAssemblyPolicy.ResetCache(); + _switches.Dispose(); + } + } + + #endregion + + #region Enforcement + + /// + /// Verifies that the policy enforces by default, and that there is exactly + /// one enforcing behavior: the only alternative is the legacy escape hatch. + /// + [Fact] + public void Policy_EnforcesByDefault() + { + using PolicyScope scope = new(); + + Assert.False(UdtAssemblyPolicy.LegacyBehaviorEnabled); + } + + /// + /// Verifies that the legacy switch disables the policy entirely. + /// + [Fact] + public void Policy_LegacySwitch_DisablesEnforcement() + { + using PolicyScope scope = new(legacy: true); + + Assert.True(UdtAssemblyPolicy.LegacyBehaviorEnabled); + } + + #endregion + + #region SqlServerTypes + + /// + /// Verifies that the built-in SQL Server CLR types assembly is recognized + /// case-insensitively and is permitted in every non-legacy mode. + /// + [Fact] + public void IsAllowed_SqlServerTypes_IsPermitted() + { + using PolicyScope scope = new(); + + Assert.True(UdtAssemblyPolicy.IsSqlServerTypesAssembly( + new AssemblyName("microsoft.sqlserver.types"))); + Assert.True(IsAllowed( + new AssemblyName("Microsoft.SqlServer.Types"), null)); + } + + /// + /// Verifies that permitting the built-in types assembly also normalizes both + /// its version and its public key token, so a server that omits or forges + /// the token cannot cause a partial-name bind that an unsigned same-named + /// assembly could satisfy. The two must happen together: the exemption is + /// granted on the simple name alone, so an unpinned reference would let an + /// arbitrary assembly borrow the name. + /// + [Fact] + public void IsAllowed_SqlServerTypes_PinsVersionAndPublicKeyToken() + { + using PolicyScope scope = new(); + + // A reference as an attacker-controlled server might send it: the right + // simple name, but a bogus version and no strong-name identity. + AssemblyName asmRef = new("Microsoft.SqlServer.Types") + { + Version = new Version(1, 2, 3, 4), + }; + + Assert.True(IsAllowed(asmRef, new Version(14, 0, 0, 0))); + + Assert.Equal(new Version(14, 0, 0, 0), asmRef.Version); + Assert.Equal( + SqlServerTypesPublicKeyToken, + ToHex(asmRef.GetPublicKeyToken())); + } + + /// + /// Verifies that pinning overwrites a public key token supplied by the + /// server rather than trusting it. + /// + [Fact] + public void IsAllowed_SqlServerTypes_OverwritesServerSuppliedToken() + { + using PolicyScope scope = new(); + + AssemblyName asmRef = new( + "Microsoft.SqlServer.Types, Version=1.0.0.0, Culture=neutral, PublicKeyToken=0123456789abcdef"); + + Assert.True(IsAllowed(asmRef, new Version(11, 0, 0, 0))); + + Assert.Equal( + SqlServerTypesPublicKeyToken, + ToHex(asmRef.GetPublicKeyToken())); + } + + /// + /// Verifies that the public key token is still pinned when no type system + /// version is available to pin the version to, which is the case for callers + /// that have no connection context. + /// + [Fact] + public void IsAllowed_SqlServerTypes_WithoutVersion_StillPinsToken() + { + using PolicyScope scope = new(); + + AssemblyName asmRef = new("Microsoft.SqlServer.Types, Version=1.0.0.0"); + + Assert.True(IsAllowed(asmRef, null)); + + Assert.Equal(new Version(1, 0, 0, 0), asmRef.Version); + Assert.Equal( + SqlServerTypesPublicKeyToken, + ToHex(asmRef.GetPublicKeyToken())); + } + + #endregion + + #region Deny by default + + /// + /// Verifies that an assembly the process has never heard of is denied in + /// both enforcing modes. This is the reporter's scenario: a server-supplied + /// name that resolves to a DLL planted on the probing path. + /// + [Fact] + public void IsAllowed_UnknownAssembly_IsDenied() + { + using PolicyScope scope = new(); + + Assert.False(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + } + + /// + /// Verifies that legacy mode permits everything, restoring the behavior that + /// predates the policy. + /// + [Fact] + public void IsAllowed_LegacyMode_PermitsEverything() + { + using PolicyScope scope = new(legacy: true); + + Assert.True(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + } + + #endregion + + #region Loaded assemblies + + /// + /// Verifies that an assembly already loaded into the process is permitted. + /// Re-resolving a loaded assembly cannot bring anything new into the + /// process, so this tier costs nothing. + /// + [Fact] + public void Resolve_LoadedAssembly_IsPermitted() + { + // This test assembly is, by definition, loaded. + Assembly self = typeof(UdtAssemblyPolicyTest).Assembly; + + using PolicyScope scope = new(); + + Assert.True(UdtAssemblyPolicy.IsPermitted( + new AssemblyName(self.GetName().Name!), null, out Assembly? resolved)); + Assert.Same(self, resolved); + } + + /// + /// Verifies that a reference permitted because the process already holds + /// that simple name resolves to the loaded instance, and that the + /// server-supplied version and public key token are discarded. + /// + /// + /// Matching on the simple name and then handing the server's full reference + /// to the loader would let a server name a loaded assembly with a different + /// identity and still cause a genuinely new load, which is precisely what + /// this tier must not permit. + /// + [Fact] + public void Resolve_LoadedAssembly_IgnoresServerSuppliedIdentity() + { + Assembly self = typeof(UdtAssemblyPolicyTest).Assembly; + string simpleName = self.GetName().Name!; + + using PolicyScope scope = new(); + + AssemblyName hostile = new( + $"{simpleName}, Version=9.9.9.9, Culture=neutral, PublicKeyToken=0123456789abcdef"); + + Assert.True(UdtAssemblyPolicy.IsPermitted(hostile, null, out Assembly? resolved)); + Assert.Same(self, resolved); + Assert.NotEqual(new Version(9, 9, 9, 9), resolved!.GetName().Version); + } + + /// + /// Verifies that an assembly which is merely statically referenced by a + /// loaded assembly, but is not itself loaded, is denied. + /// + /// + /// Loading a referenced-but-unloaded assembly is a genuinely new load, and + /// keeping new loads under the application's control rather than the + /// server's is the entire point of this policy. An application whose custom + /// UDT assembly is not yet loaded must name it on the allow list. + /// + [Fact] + public void Resolve_ReferencedButUnloadedAssembly_IsDenied() + { + HashSet loaded = new( + AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic) + .Select(a => a.GetName().Name!), + StringComparer.OrdinalIgnoreCase); + + // The driver references a number of assemblies that a unit test run + // never causes to be loaded (the identity and Azure stacks, for + // example), which makes this a far more reliable source of a + // referenced-but-unloaded assembly than the test assembly's own + // references. + AssemblyName? referencedNotLoaded = typeof(SqlConnection).Assembly + .GetReferencedAssemblies() + .FirstOrDefault(r => !loaded.Contains(r.Name!)); + + // Assert the precondition rather than returning quietly. If every + // referenced assembly is loaded then this test proves nothing, and that + // should be visible rather than counted as a pass. + Assert.True( + referencedNotLoaded is not null, + "Expected the driver to reference at least one assembly that is not loaded, " + + "so that the referenced-is-not-trusted rule can be exercised."); + + using PolicyScope scope = new(); + + Assert.False(IsAllowed(new AssemblyName(referencedNotLoaded!.Name!), null)); + } + + #endregion + + #region Allow list + + /// + /// Verifies that a simple-name allow list entry permits the assembly in + /// every enforcing mode, and that it does so regardless of the version, + /// culture, and public key token the server supplies. + /// + [Fact] + public void IsAllowed_AllowListSimpleName_Permits() + { + using PolicyScope scope = new(); + PolicyScope.SetAllowList(UnknownAssemblyName); + + Assert.True(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + Assert.True(IsAllowed(new AssemblyName( + $"{UnknownAssemblyName}, Version=9.9.9.9, Culture=neutral, PublicKeyToken=0123456789abcdef"), null)); + } + + /// + /// Verifies that allow list matching is case-insensitive on the simple name + /// and tolerates surrounding whitespace and empty entries. + /// + [Fact] + public void IsAllowed_AllowList_IgnoresCaseAndWhitespace() + { + using PolicyScope scope = new(); + PolicyScope.SetAllowList($" ; {UnknownAssemblyName.ToUpperInvariant()} ; "); + + Assert.True(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + } + + /// + /// Verifies that a fully-qualified allow list entry is matched on every + /// component it specifies, so an assembly that merely borrows the simple + /// name is still denied. + /// + [Fact] + public void IsAllowed_AllowListFullName_MatchesAllSpecifiedComponents() + { + using PolicyScope scope = new(); + PolicyScope.SetAllowList( + $"{UnknownAssemblyName}, Version=1.0.0.0, Culture=neutral, PublicKeyToken=0123456789abcdef"); + + // Exact match. + Assert.True(IsAllowed(new AssemblyName( + $"{UnknownAssemblyName}, Version=1.0.0.0, Culture=neutral, PublicKeyToken=0123456789abcdef"), null)); + + // Wrong version. + Assert.False(IsAllowed(new AssemblyName( + $"{UnknownAssemblyName}, Version=2.0.0.0, Culture=neutral, PublicKeyToken=0123456789abcdef"), null)); + + // Wrong public key token. + Assert.False(IsAllowed(new AssemblyName( + $"{UnknownAssemblyName}, Version=1.0.0.0, Culture=neutral, PublicKeyToken=fedcba9876543210"), null)); + + // No public key token at all. + Assert.False(IsAllowed(new AssemblyName( + $"{UnknownAssemblyName}, Version=1.0.0.0, Culture=neutral"), null)); + } + + /// + /// Verifies that a malformed allow list entry is skipped without throwing + /// and without widening the policy, while valid entries alongside it still + /// take effect. + /// + [Fact] + public void IsAllowed_MalformedAllowListEntry_IsSkipped() + { + using PolicyScope scope = new(); + PolicyScope.SetAllowList($", , Version=bogus ; {UnknownAssemblyName}"); + + Assert.True(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + Assert.False(IsAllowed(new AssemblyName("Some.Other.Assembly"), null)); + } + + /// + /// Verifies that changing the allow list at runtime takes effect, i.e. that + /// the cached parse is keyed on the source string. + /// + [Fact] + public void IsAllowed_AllowListChange_IsObserved() + { + using PolicyScope scope = new(); + + Assert.False(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + + AppDomain.CurrentDomain.SetData( + UdtAssemblyPolicy.AllowListAppContextDataName, + UnknownAssemblyName); + + Assert.True(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + } + + /// + /// Verifies that an assembly reference with no simple name is denied rather + /// than falling through to a load attempt. + /// + [Fact] + public void IsAllowed_EmptySimpleName_IsDenied() + { + using PolicyScope scope = new(); + + Assert.False(IsAllowed(new AssemblyName(), null)); + } + + #endregion + + #region Identity enforcement + + /// + /// Verifies that an assembly whose simple name differs from the one the + /// policy permitted is refused, even when the allow list entry constrained + /// nothing else. + /// + /// + /// Every basis for permitting a load rests on the simple name, so a + /// resolver that answers the request with an unrelated assembly would + /// otherwise have it inherit a permission that was never granted to it. + /// A simple-name entry is the weakest case: it constrains no version, + /// culture or token, so the name is the only thing left to verify. + /// + [Fact] + public void TryLoad_ResolverReturnsDifferentAssembly_IsRefused() + { + using PolicyScope scope = new(UnknownAssemblyName); + + Assembly substitute = typeof(string).Assembly; + + Assert.NotEqual( + UnknownAssemblyName, + substitute.GetName().Name, + StringComparer.OrdinalIgnoreCase); + + ResolveEventHandler handler = (_, args) => + new AssemblyName(args.Name).Name == UnknownAssemblyName ? substitute : null; + + AppDomain.CurrentDomain.AssemblyResolve += handler; + + try + { + bool loaded = UdtAssemblyPolicy.TryLoad( + new AssemblyName(UnknownAssemblyName), + null, + out Assembly? assembly); + + Assert.False(loaded); + Assert.Null(assembly); + } + finally + { + AppDomain.CurrentDomain.AssemblyResolve -= handler; + } + } + + /// + /// Verifies that an assembly which first arrives in the process during a + /// policy-triggered load is not subsequently permitted on the strength of + /// being "already loaded". + /// + /// + /// The already-loaded tier is meant to reflect only what the application + /// brought in of its own accord. The loaded-assembly map is built lazily, + /// so if the first policy call is permitted by the allow list, the load it + /// performs happens before the map exists and its dependencies would be + /// captured by the later snapshot, silently inheriting that permission. + /// This is the transitive trust the policy documents as denied. + /// + [Fact] + public void AlreadyLoaded_AssemblyArrivingDuringPolicyLoad_IsNotPermitted() + { + // An assembly that ships with the framework but is not loaded in this + // process, standing in for a dependency pulled in by a permitted load. + string? dependencyPath = FindUnloadedFrameworkAssembly(out string? dependencyName); + + Assert.NotNull(dependencyPath); + Assert.NotNull(dependencyName); + + using PolicyScope scope = new(UnknownAssemblyName); + + // Drop the map so this is the first policy call, which is the ordering + // the bug depended on. + UdtAssemblyPolicy.ResetCache(); + + ResolveEventHandler handler = (_, args) => + { + if (new AssemblyName(args.Name).Name == UnknownAssemblyName) + { + // Bring the stand-in dependency into the process during the + // policy's own load, then decline to satisfy the request. + Assembly.LoadFrom(dependencyPath!); + } + + return null; + }; + + AppDomain.CurrentDomain.AssemblyResolve += handler; + + try + { + UdtAssemblyPolicy.TryLoad(new AssemblyName(UnknownAssemblyName), null, out _); + } + catch (FileNotFoundException) + { + // Expected: the handler declines to satisfy the request, so the + // load fails. Production callers catch this the same way. What + // matters is the side effect it had on the loaded-assembly map. + } + finally + { + AppDomain.CurrentDomain.AssemblyResolve -= handler; + } + + // The dependency is now loaded, but the application never asked for it, + // so a server naming it must still be refused. + Assert.False(IsAllowed(new AssemblyName(dependencyName!), null)); + } + + /// + /// Locates a framework assembly that is present on disk but not loaded in + /// this process, to stand in for a dependency arriving during a load. + /// + private static string? FindUnloadedFrameworkAssembly(out string? simpleName) + { + HashSet loaded = new(StringComparer.OrdinalIgnoreCase); + + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + string? name = assembly.GetName().Name; + + if (name is not null) + { + loaded.Add(name); + } + } + catch + { + // An assembly whose name cannot be read cannot collide with the + // candidate either, so it is simply skipped. + } + } + + string directory = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + + foreach (string path in Directory.GetFiles(directory, "System.*.dll")) + { + string candidate = Path.GetFileNameWithoutExtension(path); + + if (!loaded.Contains(candidate)) + { + simpleName = candidate; + + return path; + } + } + + simpleName = null; + + return null; + } + + /// + /// Verifies that an allow list entry which explicitly requires an unsigned + /// assembly (PublicKeyToken=null) is not satisfied by a signed one. + /// + /// + /// AssemblyName represents an omitted public key token as null and an + /// explicit PublicKeyToken=null as a zero-length array. Treating the + /// two alike would silently widen an entry that was written to pin an + /// unsigned assembly into one that accepts any identity. + /// + [Fact] + public void AllowList_ExplicitNullToken_DoesNotPermitSignedAssembly() + { + using PolicyScope scope = new($"{UnknownAssemblyName}, PublicKeyToken=null"); + + // The entry is satisfied by an unsigned candidate. + Assert.True(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + + // ... but not by one that carries a strong name token. + AssemblyName signed = new(UnknownAssemblyName); + signed.SetPublicKeyToken(new byte[] { 0xb0, 0x3f, 0x5f, 0x7f, 0x11, 0xd5, 0x0a, 0x3a }); + + Assert.False(IsAllowed(signed, null)); + } + + /// + /// Verifies that an allow list entry which omits the public key token still + /// permits any identity, which is the documented simple-name behavior. + /// + [Fact] + public void AllowList_OmittedToken_PermitsAnyIdentity() + { + using PolicyScope scope = new(UnknownAssemblyName); + + AssemblyName signed = new(UnknownAssemblyName); + signed.SetPublicKeyToken(new byte[] { 0xb0, 0x3f, 0x5f, 0x7f, 0x11, 0xd5, 0x0a, 0x3a }); + + Assert.True(IsAllowed(signed, null)); + Assert.True(IsAllowed(new AssemblyName(UnknownAssemblyName), null)); + } + + /// + /// Verifies that the culture of the built-in SQL CLR types assembly is + /// pinned to neutral rather than left under the server's control. + /// + /// + /// The shipped assembly is culture neutral. Leaving a server-supplied + /// Culture= in place would let the reference steer the bind towards a + /// name the real assembly never uses. + /// + [Fact] + public void SqlServerTypes_PinsCultureToNeutral() + { + using PolicyScope scope = new(); + + AssemblyName asmRef = new( + $"{UdtAssemblyPolicy.SqlServerTypesAssemblyName}, Version=14.0.0.0, Culture=en-US"); + + Assert.True(IsAllowed(asmRef, new Version(14, 0, 0, 0))); + + // AssemblyName represents the neutral culture as the empty string. + Assert.Equal(string.Empty, asmRef.CultureName); + } + + /// + /// Verifies that an assembly the loader returns with an identity that does + /// not match what the policy required is refused. + /// + /// + /// The two supported runtimes refuse at different points, and this test + /// accepts either, because both are the same outcome: the assembly is not + /// used. + /// + /// On .NET the loader ignores the public key token in an AssemblyName, so + /// it returns the real assembly and the policy's own post-load check is what + /// rejects it. On .NET Framework the loader enforces the strong name during + /// binding and throws instead, which is the same distinction + /// SqlAuthenticationProviderManager documents. Asserting only the .NET shape + /// would fail on net462, and asserting only the net462 shape would let the + /// post-load check regress unnoticed on .NET. + /// + /// The subject is a framework assembly rather than the test assembly so the + /// test does not depend on whether the build is strong-name signed. + /// + [Fact] + public void TryLoad_AssemblyWithWrongToken_IsRefused() + { + // A framework assembly is certain to exist and to carry a strong name + // token that is not the fabricated one below. + AssemblyName subject = typeof(object).Assembly.GetName(); + + byte[] wrongToken = { 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef }; + + Assert.False( + wrongToken.AsSpan().SequenceEqual((subject.GetPublicKeyToken() ?? Array.Empty()).AsSpan()), + "The fabricated token must differ from the real one for this test to mean anything."); + + using PolicyScope scope = new($"{subject.Name}, PublicKeyToken={ToHex(wrongToken)}"); + + // The server names the assembly with the very token the allow list + // requires, so the entry matches and the load proceeds. Without a + // post-load check the assembly would then be accepted on the strength of + // a token it does not actually carry. + AssemblyName serverSupplied = new(subject.Name!); + serverSupplied.SetPublicKeyToken((byte[])wrongToken.Clone()); + + bool permitted; + Assembly? loaded = null; + + try + { + permitted = UdtAssemblyPolicy.TryLoad(serverSupplied, null, out loaded); + } + catch (Exception e) when (e is FileLoadException or FileNotFoundException or BadImageFormatException) + { + // .NET Framework: the loader refused the bind outright. + return; + } + + // .NET: the loader returned the real assembly and the policy rejected it. + Assert.False(permitted); + Assert.Null(loaded); + } + + /// + /// Verifies that a permitted, genuinely loadable assembly is returned by the + /// load path, so that the identity checks above are not simply refusing + /// everything. + /// + [Fact] + public void TryLoad_AllowListedAssembly_IsLoaded() + { + AssemblyName self = typeof(UdtAssemblyPolicyTest).Assembly.GetName(); + + using PolicyScope scope = new(self.Name!); + + Assert.True(UdtAssemblyPolicy.TryLoad(new AssemblyName(self.Name!), null, out Assembly? loaded)); + Assert.NotNull(loaded); + Assert.Equal(self.Name, loaded!.GetName().Name); + } + + /// + /// Verifies that a version constraint the policy relied on is confirmed + /// against the assembly that was actually loaded. + /// + /// + /// A binding redirect on .NET Framework, or a custom resolver on .NET, can + /// satisfy a request with a different version than the one asked for. An + /// allow list entry that pinned a version must therefore not be satisfied by + /// whatever the loader chose to substitute. + /// + [Fact] + public void TryLoad_AssemblyWithWrongVersion_IsRefused() + { + AssemblyName subject = typeof(object).Assembly.GetName(); + + Version wrongVersion = new(subject.Version!.Major + 100, 0, 0, 0); + + using PolicyScope scope = new( + $"{subject.Name}, Version={wrongVersion}, PublicKeyToken={ToHex(subject.GetPublicKeyToken())}"); + + AssemblyName serverSupplied = new(subject.Name!) { Version = wrongVersion }; + serverSupplied.SetPublicKeyToken(subject.GetPublicKeyToken()); + + bool permitted; + Assembly? loaded = null; + + try + { + permitted = UdtAssemblyPolicy.TryLoad(serverSupplied, null, out loaded); + } + catch (Exception e) when (e is FileLoadException or FileNotFoundException or BadImageFormatException) + { + return; + } + + Assert.False(permitted); + Assert.Null(loaded); + } + + #endregion + + #region Helpers + + private static string? ToHex(byte[]? bytes) + { + if (bytes is null) + { + return null; + } + + char[] chars = new char[bytes.Length * 2]; + for (int i = 0; i < bytes.Length; i++) + { + chars[i * 2] = GetHexDigit(bytes[i] >> 4); + chars[(i * 2) + 1] = GetHexDigit(bytes[i] & 0xF); + } + + return new string(chars); + } + + private static char GetHexDigit(int value) => + (char)(value < 10 ? '0' + value : 'a' + (value - 10)); + + #endregion +}