Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,17 @@ private static SniNpHandle CreateNpHandle(DataSource details, TimeoutTimer timeo
SniCommon.ReportSNIError(SniProviders.NP_PROV, 0, SniCommon.MultiSubnetFailoverWithNonTcpProtocol, Strings.SNI_ERROR_49);
return null;
}

// Final safeguard: never hand a pipe path whose host component contains a colon to the
// OS. DataSource transcribes IPv6 literals during parsing, so anything still holding a
// colon here is malformed. See DataSource.GetUncCompatibleHostName for details.
if (string.IsNullOrEmpty(details.PipeHostName) || details.PipeHostName.IndexOf(':') != -1)
{
SqlClientEventSource.Log.TrySNITraceEvent(nameof(SniProxy), EventType.ERR, "Invalid host name '{0}' for Named Pipes.", details.PipeHostName);
SniCommon.ReportSNIError(SniProviders.NP_PROV, 0, SniCommon.InvalidConnStringError, Strings.SNI_ERROR_25);
return null;
}

return new SniNpHandle(details.PipeHostName, details.PipeName, timeout, tlsFirst, hostNameInCertificate, serverCertificateFilename);
}

Expand Down Expand Up @@ -339,6 +350,7 @@ internal class DataSource
private const string DefaultPipeName = "sql\\query";
private const string InstancePrefix = "MSSQL$";
private const string PathSeparator = "\\";
private const string IPv6LiteralHostSuffix = ".ipv6-literal.net";

internal enum Protocol { TCP, NP, None, Admin };

Expand Down Expand Up @@ -632,7 +644,7 @@ private bool InferNamedPipesInformation()
{
// NamedPipeClientStream object will create the network path using PipeHostName and PipeName
// and can be seen in its _normalizedPipePath variable in the format \\servername\pipe\MSSQL$<instancename>\sql\query
PipeHostName = ServerName = tokensByBackSlash[0];
ServerName = tokensByBackSlash[0];
PipeName = $"{InstancePrefix}{tokensByBackSlash[1]}{PathSeparator}{DefaultPipeName}";
}
else
Expand All @@ -643,10 +655,23 @@ private bool InferNamedPipesInformation()
}
else
{
PipeHostName = ServerName = _dataSourceAfterTrimmingProtocol;
ServerName = _dataSourceAfterTrimmingProtocol;
PipeName = SniNpHandle.DefaultPipePath;
}

// An IPv6 literal must be transcribed before it can appear in a UNC pipe path,
// and ServerName must drop any brackets because it feeds DNS resolution and SPN
// construction. See GetUncCompatibleHostName and NormalizeHostName for details.
PipeHostName = GetUncCompatibleHostName(ServerName);
Comment thread
cheenamalhotra marked this conversation as resolved.
if (PipeHostName is null)
{
SqlClientEventSource.Log.TrySNITraceEvent(nameof(SniProxy), EventType.ERR, "Invalid host name '{0}' for Named Pipes.", ServerName);
ReportSNIError(SniProviders.NP_PROV);
return false;
}

ServerName = NormalizeHostName(ServerName);

InferLocalServerName();
return true;
}
Expand Down Expand Up @@ -700,10 +725,22 @@ private bool InferNamedPipesInformation()
InstanceName = PipeToken + PipeName;
}

ServerName = IsLocalHost(host) ? Environment.MachineName : host;
// An IPv6 literal must be transcribed before it can appear in a UNC pipe path.
// See GetUncCompatibleHostName for details.
string uncHost = GetUncCompatibleHostName(host);
if (uncHost is null)
{
SqlClientEventSource.Log.TrySNITraceEvent(nameof(SniProxy), EventType.ERR, "Invalid host name '{0}' for Named Pipes.", host);
ReportSNIError(SniProviders.NP_PROV);
return false;
}

// ServerName drops any brackets because it feeds DNS resolution and SPN
// construction, neither of which accepts the bracketed spelling.
ServerName = IsLocalHost(host) ? Environment.MachineName : NormalizeHostName(host);
// Pipe hostname is the hostname after leading \\ which should be passed down as is to open Named Pipe.
// For Named Pipes the ServerName makes sense for SPN creation only.
PipeHostName = host;
PipeHostName = uncHost;
}
catch (UriFormatException)
{
Expand All @@ -729,6 +766,99 @@ private bool InferNamedPipesInformation()

private static bool IsLocalHost(string serverName) =>
".".Equals(serverName) || "(local)".Equals(serverName) || "localhost".Equals(serverName);

/// <summary>
/// Attempts to interpret a host name as an IPv6 literal, accepting the bracketed form
/// (<c>[::1]</c>) that users may carry over from URL syntax.
/// </summary>
private static bool TryParseIPv6Literal(string hostName, out IPAddress address)
{
address = null;

// A colon is the only character that can make a host name an IPv6 literal, so anything
// without one (host names, IPv4 literals, already-transcribed names) is not a candidate.
if (string.IsNullOrEmpty(hostName) || hostName.IndexOf(':') == -1)
{
return false;
}

ReadOnlySpan<char> literal = hostName.AsSpan();
if (literal.Length > 2 && literal[0] == '[' && literal[literal.Length - 1] == ']')
{
literal = literal.Slice(1, literal.Length - 2);
}

return IPAddress.TryParse(literal, out address) &&
address.AddressFamily == AddressFamily.InterNetworkV6;
}

/// <summary>
/// Returns the canonical form of a host name: a bracketed IPv6 literal is unwrapped to its
/// unbracketed form, and every other host name is returned unchanged.
/// </summary>
/// <remarks>
/// <see cref="ServerName"/> feeds DNS resolution and SPN construction, neither of which
/// accepts the bracketed spelling, so the brackets must be dropped before it is used there.
/// </remarks>
internal static string NormalizeHostName(string hostName) =>
TryParseIPv6Literal(hostName, out IPAddress address) ? address.ToString() : hostName;

/// <summary>
/// Converts a host name into a form that can legally appear as the host component of a UNC
/// pipe path (<c>\\host\pipe\sql\query</c>), returning <see langword="null"/> if no such
/// form exists.
/// </summary>
/// <remarks>
/// A UNC host component may never contain a colon, so an IPv6 literal such as <c>::1</c>
/// cannot be used directly. Passing one through anyway composes a malformed path like
/// <c>\\::1\pipe\sql\query</c>, which sends the SMB redirector into an SMB session setup
/// whose SPNEGO/NegoEx target name embeds the IPv6 literal; that can fault LSASS on Windows
/// and force a reboot. See https://github.com/dotnet/SqlClient/issues/4523.
///
/// Windows defines a transcription for exactly this case: replace each <c>:</c> with
/// <c>-</c> and each <c>%</c> (zone index) with <c>s</c>, then append
/// <c>.ipv6-literal.net</c>. For example <c>2001:db8::1</c> becomes
/// <c>2001-db8--1.ipv6-literal.net</c>. See
/// https://learn.microsoft.com/openspecs/windows_protocols/ms-dtyp/62e862f4-2a51-452e-8eeb-dc4ff5ee33cc.
///
/// Host names without a colon (including IPv4 literals and already-transcribed
/// <c>.ipv6-literal.net</c> names) are returned unchanged. A colon-bearing host name that is
/// not a parseable IPv6 literal has no UNC form and is rejected.
/// </remarks>
internal static string GetUncCompatibleHostName(string hostName)
{
if (string.IsNullOrEmpty(hostName))
{
return null;
}

if (hostName.IndexOf(':') == -1)
{
return hostName;
}

if (!TryParseIPv6Literal(hostName, out IPAddress address))
{
return null;
}

string literal = address.ToString();
return string.Create(literal.Length + IPv6LiteralHostSuffix.Length, literal,
static (destination, value) =>
{
for (int i = 0; i < value.Length; i++)
{
destination[i] = value[i] switch
{
':' => '-',
'%' => 's',
_ => value[i]
};
}

IPv6LiteralHostSuffix.AsSpan().CopyTo(destination.Slice(value.Length));
});
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// 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.

#if NET

using Microsoft.Data.SqlClient.ManagedSni;
using Xunit;

namespace Microsoft.Data.SqlClient.UnitTests.ManagedSni
{
/// <summary>
/// Regression tests for Named Pipes data source parsing in <see cref="DataSource"/>.
///
/// A UNC path host component may never contain a colon, so an IPv6 literal server name cannot
/// be used directly. Passing one through anyway composes a malformed pipe path such as
/// <c>\\::1\pipe\sql\query</c>, which sends the SMB redirector into an SMB session setup that
/// can fault LSASS on Windows and force a reboot. Windows instead defines a transcription for
/// this case (<c>2001:db8::1</c> becomes <c>2001-db8--1.ipv6-literal.net</c>), which the parser
/// now applies so IPv6 Named Pipes connections keep working.
///
/// See: https://github.com/dotnet/SqlClient/issues/4523
/// and https://learn.microsoft.com/openspecs/windows_protocols/ms-dtyp/62e862f4-2a51-452e-8eeb-dc4ff5ee33cc
/// </summary>
public class DataSourceNamedPipesTests
{
/// <summary>
/// Verifies that an IPv6 literal host is transcribed to its <c>.ipv6-literal.net</c> UNC
/// form, covering both the <c>np:host</c> form and the <c>\\host\pipe\...</c> UNC form,
/// with and without brackets and with a zone index.
/// </summary>
[Theory]
[InlineData(@"np:::1", "--1.ipv6-literal.net")]
[InlineData(@"np:[::1]", "--1.ipv6-literal.net")]
[InlineData(@"np:2001:db8::1", "2001-db8--1.ipv6-literal.net")]
[InlineData(@"np:fe80::1%3", "fe80--1s3.ipv6-literal.net")]
[InlineData(@"\\::1\pipe\sql\query", "--1.ipv6-literal.net")]
[InlineData(@"np:\\::1\pipe\sql\query", "--1.ipv6-literal.net")]
[InlineData(@"np:\\[2001:db8::1]\pipe\MSSQL$MYINSTANCE\sql\query", "2001-db8--1.ipv6-literal.net")]
public void ParseServerName_NamedPipesWithIPv6Literal_IsTranscribedToUncForm(
string dataSource, string expectedPipeHostName)
{
DataSource details = DataSource.ParseServerName(dataSource);

Assert.NotNull(details);
Assert.Equal(DataSource.Protocol.NP, details.ResolvedProtocol);
Assert.Equal(expectedPipeHostName, details.PipeHostName);
// The pipe host name is what reaches the OS, so it must never carry a colon.
Assert.DoesNotContain(":", details.PipeHostName);
}

/// <summary>
/// Verifies that a colon-bearing host that is not a parseable IPv6 literal has no UNC form
/// and is therefore rejected, rather than composing a malformed pipe path.
/// </summary>
[Theory]
[InlineData(@"np:not:a:host")]
[InlineData(@"np:2001:db8:::::1")]
[InlineData(@"\\not:a:host\pipe\sql\query")]
public void ParseServerName_NamedPipesWithUnparseableColonHost_IsRejected(string dataSource)
{
Assert.Null(DataSource.ParseServerName(dataSource));
}

/// <summary>
/// Verifies that IPv6 transcription does not regress legitimate Named Pipes data sources:
/// IPv4 literals, <c>localhost</c>, <c>.</c>, named instances, and explicit UNC pipe paths
/// must still parse and yield an unchanged pipe host name.
/// </summary>
[Theory]
[InlineData(@"np:127.0.0.1", "127.0.0.1")]
[InlineData(@"np:localhost", "localhost")]
[InlineData(@"np:.", ".")]
[InlineData(@"np:server\instance", "server")]
[InlineData(@"\\127.0.0.1\pipe\sql\query", "127.0.0.1")]
[InlineData(@"\\.\pipe\MSSQL$MYINSTANCE\sql\query", ".")]
[InlineData(@"\\my-server\pipe\sql\query", "my-server")]
public void ParseServerName_NamedPipesWithValidHost_IsAccepted(
string dataSource, string expectedPipeHostName)
{
DataSource details = DataSource.ParseServerName(dataSource);

Assert.NotNull(details);
Assert.Equal(DataSource.Protocol.NP, details.ResolvedProtocol);
Assert.Equal(expectedPipeHostName, details.PipeHostName);
Assert.False(string.IsNullOrEmpty(details.PipeName));
}

/// <summary>
/// Verifies that a Named Pipes data source given without a UNC path still composes the
/// default pipe name, including the <c>MSSQL$&lt;instance&gt;</c> prefix for named instances.
/// These forms are asserted separately from the UNC forms because the UNC path builds its
/// pipe name with <see cref="System.IO.Path.DirectorySeparatorChar"/>, which is platform dependent.
/// </summary>
[Theory]
[InlineData(@"np:127.0.0.1", @"sql\query")]
[InlineData(@"np:localhost", @"sql\query")]
[InlineData(@"np:::1", @"sql\query")]
[InlineData(@"np:server\instance", @"MSSQL$instance\sql\query")]
public void ParseServerName_NamedPipesWithoutUncPath_ComposesDefaultPipeName(
string dataSource, string expectedPipeName)
{
DataSource details = DataSource.ParseServerName(dataSource);

Assert.NotNull(details);
Assert.Equal(expectedPipeName, details.PipeName);
}

/// <summary>
/// Verifies that an IPv6 literal is preserved (unbracketed) in <see cref="DataSource.ServerName"/>,
/// which feeds DNS resolution and SPN construction, while the pipe host name is transcribed.
/// The bracketed spelling must not survive into <see cref="DataSource.ServerName"/> because
/// neither DNS nor SPN construction accepts it.
/// </summary>
[Theory]
[InlineData(@"np:2001:db8::1")]
[InlineData(@"np:[2001:db8::1]")]
[InlineData(@"np:\\2001:db8::1\pipe\sql\query")]
[InlineData(@"np:\\[2001:db8::1]\pipe\sql\query")]
public void ParseServerName_NamedPipesWithIPv6Literal_PreservesUnbracketedServerNameForSpn(string dataSource)
{
DataSource details = DataSource.ParseServerName(dataSource);

Assert.NotNull(details);
Assert.Equal("2001:db8::1", details.ServerName);
Assert.Equal("2001-db8--1.ipv6-literal.net", details.PipeHostName);
}

/// <summary>
/// Verifies <see cref="DataSource.NormalizeHostName"/> unwraps bracketed IPv6 literals and
/// leaves every other host name untouched.
/// </summary>
[Theory]
[InlineData("[::1]", "::1")]
[InlineData("::1", "::1")]
[InlineData("[2001:db8::1]", "2001:db8::1")]
[InlineData("[fe80::1%3]", "fe80::1%3")]
[InlineData("localhost", "localhost")]
[InlineData("127.0.0.1", "127.0.0.1")]
[InlineData("not:a:host", "not:a:host")]
[InlineData("", "")]
public void NormalizeHostName_ReturnsExpected(string hostName, string expected)
{
Assert.Equal(expected, DataSource.NormalizeHostName(hostName));
}

/// <summary>
/// Without an explicit protocol prefix, managed SNI defaults to TCP, so an IPv6 literal
/// server name must continue to parse successfully and never reach the Named Pipes path.
/// </summary>
[Theory]
[InlineData("::1")]
[InlineData("[::1]")]
[InlineData("fe80::1")]
public void ParseServerName_IPv6LiteralWithoutProtocol_ResolvesToNonNamedPipes(string dataSource)
{
DataSource details = DataSource.ParseServerName(dataSource);

Assert.NotNull(details);
Assert.NotEqual(DataSource.Protocol.NP, details.ResolvedProtocol);
Comment thread
cheenamalhotra marked this conversation as resolved.
Assert.Equal(dataSource, details.ServerName);
}

/// <summary>
/// Verifies <see cref="DataSource.GetUncCompatibleHostName"/> directly: colon-free host names
/// pass through untouched, IPv6 literals are transcribed per MS-DTYP, and colon-bearing host
/// names with no IPv6 interpretation return <see langword="null"/>.
/// </summary>
[Theory]
[InlineData(".", ".")]
[InlineData("localhost", "localhost")]
[InlineData("127.0.0.1", "127.0.0.1")]
[InlineData("my-server.contoso.com", "my-server.contoso.com")]
[InlineData("--1.ipv6-literal.net", "--1.ipv6-literal.net")]
[InlineData("::1", "--1.ipv6-literal.net")]
[InlineData("[::1]", "--1.ipv6-literal.net")]
[InlineData("2001:db8::1", "2001-db8--1.ipv6-literal.net")]
[InlineData("::ffff:1.2.3.4", "--ffff-1.2.3.4.ipv6-literal.net")]
[InlineData("fe80::1%3", "fe80--1s3.ipv6-literal.net")]
Comment thread
cheenamalhotra marked this conversation as resolved.
public void GetUncCompatibleHostName_ReturnsExpected(string hostName, string expected)
{
Assert.Equal(expected, DataSource.GetUncCompatibleHostName(hostName));
}

/// <summary>
/// Verifies <see cref="DataSource.GetUncCompatibleHostName"/> returns <see langword="null"/>
/// for host names that contain a colon but have no IPv6 interpretation, and for empty input.
/// </summary>
[Theory]
[InlineData("not:a:host")]
[InlineData("2001:db8:::::1")]
[InlineData("[:]")]
[InlineData("")]
public void GetUncCompatibleHostName_UnconvertibleHost_ReturnsNull(string hostName)
{
Assert.Null(DataSource.GetUncCompatibleHostName(hostName));
}

/// <summary>
/// Verifies <see cref="DataSource.GetUncCompatibleHostName"/> returns <see langword="null"/>
/// for a null host name. Covered separately from the theory above because xUnit disallows
/// null theory data for a non-nullable string parameter.
/// </summary>
[Fact]
public void GetUncCompatibleHostName_Null_ReturnsNull()
{
Assert.Null(DataSource.GetUncCompatibleHostName(null));
}
}
}

#endif
Loading