diff --git a/.github/instructions/features.instructions.md b/.github/instructions/features.instructions.md index 34262b8db6..8dcf4bbae0 100644 --- a/.github/instructions/features.instructions.md +++ b/.github/instructions/features.instructions.md @@ -257,6 +257,78 @@ AppContext switches allow runtime behavior changes without modifying connection | `Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2` | `false` | Enables the new `ChannelDbConnectionPool` implementation | | `Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows` | `false` | Forces managed SNI on Windows (instead of native SNI) | | `Switch.Microsoft.Data.SqlClient.UseOneSecFloorInTimeoutCalculationDuringLogin` | `false` | Sets 1-second minimum in login timeout calculations | +| `Switch.Microsoft.Data.SqlClient.UseLegacyUdtAssemblyLoad` | `false` | Restores the pre-policy behavior of loading any assembly named by a server-supplied UDT assembly-qualified name, and of skipping the `[SqlUserDefinedType]` check | + +### UDT Assembly Load Policy + +A server-supplied UDT assembly-qualified name reaches `Assembly.Load`, so the +driver applies a deny-by-default policy before handing the name to the loader. +There is a single enforcing behavior, which permits: + +| Permitted | Notes | +|-----------|-------| +| `Microsoft.SqlServer.Types` | Identity pinned: the version is normalized to the connection's negotiated type system version, and the public key token to the one Microsoft signs with | +| Assemblies on the allow list | The application explicitly naming what it is willing to have loaded | +| Assemblies already loaded into the process | Resolved to the instance the process already holds; the server-supplied version and public key token are discarded | + +Everything else is refused. In particular, an assembly that is only *statically +referenced* by a loaded assembly is **not** permitted, because loading it is a +genuinely new load — precisely what this policy keeps under the application's +control rather than the server's. + +Setting `UseLegacyUdtAssemblyLoad` disables the policy entirely and restores the +pre-policy behavior. It is a temporary compatibility escape hatch, not a +supported configuration. + +Applications that use custom UDTs whose assemblies are loaded on demand must name +them explicitly through the `Microsoft.Data.SqlClient.UdtAssemblyAllowList` +AppContext data element, a semicolon-separated list of assembly names: + +```csharp +AppDomain.CurrentDomain.SetData( + "Microsoft.Data.SqlClient.UdtAssemblyAllowList", + "Contoso.Udts;Fabrikam.Udts, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"); +``` + +Each entry is matched only on the components it specifies, so a simple name +permits any version, culture, and public key token, while a fully-qualified name +must match exactly. + +Independently of the assembly policy, a resolved type that is not annotated with +`SqlUserDefinedTypeAttribute` is rejected before any of its code runs (except +under `UseLegacyUdtAssemblyLoad`). This is the gate that actually prevents +foreign code execution: on CoreCLR, neither `Assembly.Load`, nor resolving a type +from the assembly, nor reading that type's custom attributes runs anything from +it — a module initializer or static constructor runs on first real member access, +which is what `GetUdtValue` would otherwise perform. + +#### Compatibility impact + +This policy is a behavior change for applications that use **custom** UDTs. The +built-in spatial types (`SqlGeography`, `SqlGeometry`, `SqlHierarchyId`) are +unaffected, since `Microsoft.SqlServer.Types` is permitted by identity. + +An application is affected when the custom UDT's assembly is not yet loaded at +the moment the value is read. That is common whenever the *driver* materializes +the value and the application never names the type in its own code — generic data +access layers, micro-ORMs, `DataTable.Load`, and schema discovery. In those cases +the driver's own `Assembly.Load` was previously the thing that pulled the +assembly in, and it is now refused. + +The symptom depends on the API: + +| API | Symptom | +|-----|---------| +| `reader[i]`, `GetValue`, UDT output parameters | `SqlException` naming the assembly and the allow list | +| `GetFieldType`, `GetSchemaTable`, `GetColumnSchema` | Returns `null` for the UDT column's type rather than throwing | + +The second row is the harder one to diagnose, because `GetFieldType` does not +normally return `null`; a caller that dereferences the result sees an unrelated +`NullReferenceException`. A denial is always traced through +`SqlClientEventSource` regardless of which path was taken, so enabling event +source tracing will identify the assembly. + +The remedy in every case is to name the assembly on the allow list. ### Usage Example ```csharp 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..a6b828d295 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,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Reflection; namespace Microsoft.Data.SqlClient.Server { @@ -377,7 +378,20 @@ internal Type Type // Fault-in UDT clr types on access if have assembly-qualified name if (_clrType == null && SqlDbType.Udt == _databaseType && _udtAssemblyQualifiedName != null) { - _clrType = Type.GetType(_udtAssemblyQualifiedName, true); + // The assembly-qualified name can originate from the server, + // so the resolution goes through the same policy that + // SqlConnection.ResolveTypeAssembly applies. There is no + // connection context here, so no type system version is + // available to pin the built-in SQL CLR types assembly to; + // its public key token is still pinned. + _clrType = Type.GetType( + typeName: _udtAssemblyQualifiedName, + assemblyResolver: static asmRef => + UdtAssemblyPolicy.TryResolve(asmRef, typeSystemAssemblyVersion: null, out Assembly loaded) + ? loaded ?? Assembly.Load(asmRef) + : throw SQL.UdtAssemblyNotAllowed(asmRef.Name), + typeResolver: null, + throwOnError: true); } return _clrType; } 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..3c8da27e92 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs @@ -3033,13 +3033,41 @@ private void CopyFrom(SqlConnection connection) private Assembly ResolveTypeAssembly(AssemblyName asmRef, bool throwOnError) { Debug.Assert(TypeSystemAssemblyVersion != null, "TypeSystemAssembly should be set !"); - if (string.Equals(asmRef.Name, "Microsoft.SqlServer.Types", StringComparison.OrdinalIgnoreCase)) + + if (UdtAssemblyPolicy.IsSqlServerTypesAssembly(asmRef) && + asmRef.Version != TypeSystemAssemblyVersion && + SqlClientEventSource.Log.IsTraceEnabled()) + { + SqlClientEventSource.Log.TryTraceEvent("SqlConnection.ResolveTypeAssembly | SQL CLR type version change: Server sent {0}, client will instantiate {1}", asmRef.Version, TypeSystemAssemblyVersion); + } + + // The assembly name arrives from the server, so the driver must + // decide whether it is willing to bring this assembly into the + // process before it hands the name to the loader. This call also + // pins the identity (version and public key token) of the built-in + // SQL CLR types assembly, so that the built-in exemption cannot be + // satisfied by a same-named assembly that happens to sit on the + // probing path. + if (!UdtAssemblyPolicy.TryResolve(asmRef, TypeSystemAssemblyVersion, out Assembly alreadyLoaded)) { - if (asmRef.Version != TypeSystemAssemblyVersion && SqlClientEventSource.Log.IsTraceEnabled()) + SqlClientEventSource.Log.TryTraceEvent("SqlConnection.ResolveTypeAssembly | ERR | UDT assembly '{0}' was not loaded because the UDT assembly load policy does not permit it.", asmRef.Name); + + if (throwOnError) { - SqlClientEventSource.Log.TryTraceEvent("SqlConnection.ResolveTypeAssembly | SQL CLR type version change: Server sent {0}, client will instantiate {1}", asmRef.Version, TypeSystemAssemblyVersion); + throw SQL.UdtAssemblyNotAllowed(asmRef.Name); } - asmRef.Version = TypeSystemAssemblyVersion; + + return null; + } + + // The policy permitted the reference because the process had already + // loaded an assembly of that simple name. Use that instance rather + // than binding the server-supplied version and public key token, + // which could otherwise resolve to a different assembly and cause + // the new load this policy exists to prevent. + if (alreadyLoaded != null) + { + return alreadyLoaded; } try @@ -3068,6 +3096,50 @@ internal void CheckGetExtendedUDTInfo(SqlMetaDataPriv metaData, bool fThrow) metaData.udt.Type = Type.GetType(typeName: metaData.udt.AssemblyQualifiedName, assemblyResolver: asmRef => ResolveTypeAssembly(asmRef, fThrow), typeResolver: null, throwOnError: fThrow); + // Nothing has executed any of the resolved type's code yet: + // reading its custom attributes does not run its static + // constructor. This is therefore the last point at which the + // driver can reject a type that the server named but that is not + // actually a user-defined type, and it must happen before + // GetUdtValue invokes anything on it. + // + // This check also backstops the assembly policy: a type name + // that carries no assembly part is resolved without ever + // consulting the assembly resolver, so this is the only gate a + // name such as "System.String" passes through. + if (metaData.udt.Type != null && !UdtAssemblyPolicy.LegacyBehaviorEnabled) + { + bool isUserDefinedType; + + try + { + isUserDefinedType = SqlUdtInfo.TryGetFromType(metaData.udt.Type) != null; + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + // Reading custom attributes can fail if the attribute or + // one of its arguments lives in an assembly that cannot + // be loaded. Treat that as "not a user-defined type" + // rather than letting it escape, so that callers that + // pass fThrow: false keep tolerating an unusable type. + SqlClientEventSource.Log.TryTraceEvent("SqlConnection.CheckGetExtendedUDTInfo | ERR | Unable to read the attributes of type '{0}'.", metaData.udt.AssemblyQualifiedName); + + isUserDefinedType = false; + } + + if (!isUserDefinedType) + { + SqlClientEventSource.Log.TryTraceEvent("SqlConnection.CheckGetExtendedUDTInfo | ERR | Type '{0}' is not annotated with SqlUserDefinedTypeAttribute and will not be used.", metaData.udt.AssemblyQualifiedName); + + metaData.udt.Type = null; + + if (fThrow) + { + throw SQL.UdtTypeNotUserDefined(metaData.udt.AssemblyQualifiedName); + } + } + } + if (fThrow && metaData.udt.Type == null) { throw SQL.UDTUnexpectedResult(metaData.udt.AssemblyQualifiedName); 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..57f1869b8e --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/UdtAssemblyPolicy.cs @@ -0,0 +1,489 @@ +// 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.Reflection; +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 }; + + #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. + /// + /// 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; + + #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 + /// , pinning the identity of the built-in SQL + /// Server CLR types assembly as a side effect when that is what it names. + /// + /// Pinning and the decision are deliberately performed by a single call so + /// that it is not possible to consult the policy without also pinning: the + /// built-in exemption is granted on the simple name alone, so an unpinned + /// reference would let an unsigned assembly that merely borrows the name + /// satisfy it. + /// + /// + /// 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 only the public + /// key token is pinned and the loader picks the version. + /// + /// + /// On a permitted result, the assembly the caller must use, or null when + /// the caller is to load itself. A non-null value + /// means the process had already loaded an assembly with this simple name + /// and the caller must use that instance rather than binding the + /// server-supplied identity. + /// + /// True when the assembly may be used. + internal static bool TryResolve( + AssemblyName asmRef, + Version? typeSystemAssemblyVersion, + out Assembly? assembly) + { + assembly = null; + + if (LegacyBehaviorEnabled) + { + return true; + } + + string? simpleName = asmRef.Name; + if (string.IsNullOrEmpty(simpleName)) + { + return false; + } + + // 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); + 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. + if (MatchesAllowList(asmRef)) + { + 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. + return TryGetLoadedAssembly(simpleName!, out assembly); + } + + /// + /// 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 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.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 + + /// + /// Determines whether matches an entry on the + /// application-supplied allow list. + /// + private static bool MatchesAllowList(AssemblyName asmRef) + { + List allowList = GetAllowList(); + + for (int i = 0; i < allowList.Count; i++) + { + if (Matches(allowList[i], asmRef)) + { + return true; + } + } + + 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; + } + + byte[]? allowedToken = allowed.GetPublicKeyToken(); + if (allowedToken is { Length: > 0 }) + { + byte[]? candidateToken = candidate.GetPublicKeyToken(); + if (candidateToken is null || candidateToken.Length != allowedToken.Length) + { + return false; + } + + for (int i = 0; i < allowedToken.Length; i++) + { + if (allowedToken[i] != candidateToken[i]) + { + 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() + { + string source = AppContext.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) + { + return GetLoadedAssemblies().TryGetValue(simpleName, out assembly); + } + } + + /// + /// 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; + } + + try + { + string? name = assembly.GetName().Name; + + if (!string.IsNullOrEmpty(name) && !loaded.ContainsKey(name!)) + { + loaded.Add(name!, 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."); + } + } + + /// + /// 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) => + { + 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..423d86aead --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyLoadHardeningTest.cs @@ -0,0 +1,352 @@ +// 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.Reflection; +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 , which +/// runs the target assembly's module initializer, and then invoked a static +/// member on the resolved type without checking that it was a user-defined type +/// at all, which runs the type's static constructor. These tests assert that +/// neither happens. +/// +[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. + /// + [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); + } + + /// + /// 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 Helpers + + 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 = + AppContext.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..51844d9eda --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/UdtAssemblyPolicyTest.cs @@ -0,0 +1,441 @@ +// 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.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.TryResolve(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 = + AppContext.GetData(UdtAssemblyPolicy.AllowListAppContextDataName); + + _switches.UseLegacyUdtAssemblyLoad = legacy; + + SetAllowList(null); + } + + 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.TryResolve( + 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.TryResolve(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); + + AssemblyName? referencedNotLoaded = typeof(UdtAssemblyPolicyTest).Assembly + .GetReferencedAssemblies() + .FirstOrDefault(r => !loaded.Contains(r.Name!)); + + if (referencedNotLoaded is null) + { + // Every referenced assembly happens to be loaded in this run, so + // there is nothing here to distinguish. The companion test + // Resolve_UnknownAssembly_IsDenied covers the general deny path. + return; + } + + 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 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 +}