diff --git a/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpEndPointManager.cs b/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpEndPointManager.cs index 5077554178e92b..2b84153e02a801 100644 --- a/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpEndPointManager.cs +++ b/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpEndPointManager.cs @@ -78,9 +78,11 @@ public static void AddPrefix(string prefix, HttpListener listener) private static void AddPrefixInternal(string p, HttpListener listener) { int start = p.IndexOf(':') + 3; - int colon = p.IndexOf(':', start); + int colon = ListenerPrefix.IndexOfPortSeparator(p.AsSpan(start)); if (colon != -1) { + colon += start; + // root can't be -1 here, since we've already checked for ending '/' in ListenerPrefix. int root = p.IndexOf('/', colon, p.Length - colon); ReadOnlySpan portString = p.AsSpan(colon + 1, root - colon - 1); @@ -96,6 +98,10 @@ private static void AddPrefixInternal(string p, HttpListener listener) if (lp.Host != "*" && lp.Host != "+" && Uri.CheckHostName(lp.Host) == UriHostNameType.Unknown) throw new HttpListenerException((int)HttpStatusCode.BadRequest, SR.net_listener_host); + // IPv6 literals with a scope ID ("[fe80::1%25eth0]") are not supported. + if (lp.Host!.Contains('%')) + throw new HttpListenerException((int)HttpStatusCode.BadRequest, SR.net_listener_host); + if (lp.Path!.Contains('%')) throw new HttpListenerException((int)HttpStatusCode.BadRequest, SR.net_invalid_path); diff --git a/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpListenerRequest.Managed.cs b/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpListenerRequest.Managed.cs index 95157b7b45552f..f3846928d2a1ff 100644 --- a/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpListenerRequest.Managed.cs +++ b/src/libraries/System.Net.HttpListener/src/System/Net/Managed/HttpListenerRequest.Managed.cs @@ -161,7 +161,7 @@ internal void FinishInitialization() if (raw_uri != null) host = raw_uri.Host; - int colon = host.IndexOf(':'); + int colon = ListenerPrefix.IndexOfPortSeparator(host); if (colon >= 0) host = host.Slice(0, colon); diff --git a/src/libraries/System.Net.HttpListener/src/System/Net/Managed/ListenerPrefix.cs b/src/libraries/System.Net.HttpListener/src/System/Net/Managed/ListenerPrefix.cs index a9b72e12d2b444..6cd7aa686670d1 100644 --- a/src/libraries/System.Net.HttpListener/src/System/Net/Managed/ListenerPrefix.cs +++ b/src/libraries/System.Net.HttpListener/src/System/Net/Managed/ListenerPrefix.cs @@ -109,10 +109,11 @@ private void Parse(string uri) if (start_host >= length) throw new ArgumentException(SR.net_listener_host); - int colon = uri.IndexOf(':', start_host, length - start_host); + int colon = IndexOfPortSeparator(uri.AsSpan(start_host)); int root; - if (colon > 0) + if (colon >= 0) { + colon += start_host; _host = uri.Substring(start_host, colon - start_host); root = uri.IndexOf('/', colon, length - colon); _port = (ushort)int.Parse(uri.AsSpan(colon + 1, root - colon - 1)); @@ -127,6 +128,24 @@ private void Parse(string uri) } if (_path.Length != 1) _path = _path.Substring(0, _path.Length - 1); + + // Requests are matched against Uri.Host, which writes an IPv6 literal in canonical form + // ("[::1]", lower-case hex), so store the prefix's literal the same way. A literal with a + // scope ID is left as written so that HttpEndPointManager can reject it. + if (_host.StartsWith('[') && !_host.Contains('%') && IPAddress.TryParse(_host, out IPAddress? address)) + { + _host = $"[{address}]"; + } + } + + // Returns the index of the ':' that separates the host from the port in "host[:port]...", + // or -1 if there is none. An IPv6 literal ("[::1]") contains colons of its own, so for a + // host that starts with '[' the separator is the first ':' after the closing ']'. + internal static int IndexOfPortSeparator(ReadOnlySpan hostAndPort) + { + int start = hostAndPort.StartsWith('[') ? Math.Max(hostAndPort.IndexOf(']'), 0) : 0; + int colon = hostAndPort.Slice(start).IndexOf(':'); + return colon < 0 ? -1 : start + colon; } } } diff --git a/src/libraries/System.Net.HttpListener/tests/GetContextHelper.cs b/src/libraries/System.Net.HttpListener/tests/GetContextHelper.cs index b88e360faf4989..2a5cb195ddbba2 100644 --- a/src/libraries/System.Net.HttpListener/tests/GetContextHelper.cs +++ b/src/libraries/System.Net.HttpListener/tests/GetContextHelper.cs @@ -52,6 +52,31 @@ public static class Helpers private static bool TypeExists(string name) => typeof(HttpListener).Assembly.GetType(name, throwOnError: false, ignoreCase: false) != null; + // Whether a test can listen on http://[::1]:port/. Socket.OSSupportsIPv6 can be true on a host where ::1 cannot + // be bound (e.g. IPv6 disabled on the loopback interface), and http.sys may refuse to register an IP literal + // prefix for a process that is not elevated. + public static bool CanListenOnIPv6Loopback { get; } = + (IsManagedImplementation || PlatformDetection.IsPrivilegedProcess) && IsIPv6LoopbackAvailable(); + + private static bool IsIPv6LoopbackAvailable() + { + if (!Socket.OSSupportsIPv6) + { + return false; + } + + try + { + using var socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.IPv6Loopback, 0)); + return true; + } + catch (SocketException) + { + return false; + } + } + public static void WaitForSocketShutdown(Socket socket) { socket.Close(); diff --git a/src/libraries/System.Net.HttpListener/tests/HttpListenerIPv6Tests.cs b/src/libraries/System.Net.HttpListener/tests/HttpListenerIPv6Tests.cs new file mode 100644 index 00000000000000..f92fea7286b274 --- /dev/null +++ b/src/libraries/System.Net.HttpListener/tests/HttpListenerIPv6Tests.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace System.Net.Tests +{ + [ActiveIssue("https://github.com/dotnet/runtime/issues/2391", TestRuntimes.Mono)] + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))] // httpsys component missing in Nano. + public class HttpListenerIPv6Tests + { + [ConditionalTheory(typeof(Helpers), nameof(Helpers.CanListenOnIPv6Loopback))] + [InlineData("[::1]")] + [InlineData("[0:0:0:0:0:0:0:1]")] + public async Task Prefix_IPv6Literal_ServesRequest(string literal) + { + using var factory = new HttpListenerFactory(literal); + using Socket client = factory.GetConnectedSocket(); + string hostHeader = $"[::1]:{factory.Port}"; + + HttpListenerContext context = await GetContextAsync(factory.GetListener(), client, + $"GET /{factory.Path}/ HTTP/1.1\r\nHost: {hostHeader}\r\n\r\n"); + + Assert.Equal($"http://[::1]:{factory.Port}/{factory.Path}/", context.Request.Url.ToString()); + Assert.Equal(hostHeader, context.Request.UserHostName); + Assert.Equal(IPAddress.IPv6Loopback, context.Request.LocalEndPoint.Address); + context.Response.Close(); + } + + [ConditionalTheory(typeof(Helpers), nameof(Helpers.CanListenOnIPv6Loopback))] + [InlineData("GET /{0}/ HTTP/1.1\r\nHost: [::1]:{1}\r\n\r\n")] + [InlineData("GET http://[::1]:{1}/{0}/ HTTP/1.1\r\nHost: [::1]:{1}\r\n\r\n")] + [InlineData("GET /{0}/ HTTP/1.0\r\n\r\n")] + public async Task Request_IPv6LiteralPrefix_ReturnsExpectedUrl(string requestFormat) + { + using var factory = new HttpListenerFactory("[::1]"); + using Socket client = factory.GetConnectedSocket(); + + HttpListenerContext context = await GetContextAsync(factory.GetListener(), client, + string.Format(requestFormat, factory.Path, factory.Port)); + + Assert.Equal($"http://[::1]:{factory.Port}/{factory.Path}/", context.Request.Url.ToString()); + context.Response.Close(); + } + + [ConditionalFact(typeof(HttpListenerFactory), nameof(HttpListenerFactory.SupportsWildcards))] + public async Task Request_IPv6LiteralHostHeaderToWildcardPrefix_ReturnsExpectedUrl() + { + using var factory = new HttpListenerFactory("+"); + using Socket client = factory.GetConnectedSocket(); + string hostHeader = $"[::1]:{factory.Port}"; + + HttpListenerContext context = await GetContextAsync(factory.GetListener(), client, + $"GET /{factory.Path}/ HTTP/1.1\r\nHost: {hostHeader}\r\n\r\n"); + + Assert.Equal($"http://[::1]:{factory.Port}/{factory.Path}/", context.Request.Url.ToString()); + Assert.Equal(hostHeader, context.Request.UserHostName); + context.Response.Close(); + } + + private static async Task GetContextAsync(HttpListener listener, Socket client, string request) + { + Task getContext = listener.GetContextAsync(); + await client.SendAsync(Encoding.ASCII.GetBytes(request)); + + // A request the listener rejects is answered on the socket and never reaches GetContextAsync. + byte[] buffer = new byte[4096]; + Task receive = client.ReceiveAsync(buffer.AsMemory(), SocketFlags.None).AsTask(); + Task first = await Task.WhenAny(getContext, receive).WaitAsync(TimeSpan.FromSeconds(30)); + if (first == receive) + { + Assert.Fail($"The listener answered the request itself: {Encoding.ASCII.GetString(buffer, 0, await receive)}"); + } + + return await getContext; + } + } +} diff --git a/src/libraries/System.Net.HttpListener/tests/HttpListenerPrefixCollectionTests.cs b/src/libraries/System.Net.HttpListener/tests/HttpListenerPrefixCollectionTests.cs index 038bc82ae97d83..fbd3e39c181569 100644 --- a/src/libraries/System.Net.HttpListener/tests/HttpListenerPrefixCollectionTests.cs +++ b/src/libraries/System.Net.HttpListener/tests/HttpListenerPrefixCollectionTests.cs @@ -264,6 +264,11 @@ public static IEnumerable Hosts_TestData() yield return new object[] { "*" }; yield return new object[] { "+" }; } + + if (Helpers.CanListenOnIPv6Loopback) + { + yield return new object[] { "[::1]" }; + } } [Theory] @@ -352,6 +357,12 @@ public static IEnumerable InvalidPrefix_TestData() yield return new object[] { "http://localhost:65536/" }; yield return new object[] { "http://localhost:/" }; yield return new object[] { "http://localhost:trash/" }; + yield return new object[] { "http://[::1]:-1/" }; + yield return new object[] { "http://[::1]:0/" }; + yield return new object[] { "http://[::1]:65536/" }; + yield return new object[] { "http://[::1]:/" }; + yield return new object[] { "http://[::1]:trash/" }; + yield return new object[] { "http://[::1/" }; yield return new object[] { "http://localhost/invalid%path/" }; yield return new object[] { "http://./" }; yield return new object[] { "http://\\/" }; @@ -384,6 +395,33 @@ public void Add_InvalidPrefixAlreadyStarted_ThrowsHttpListenerExceptionOnAdd(str } } + [Theory] + [InlineData("http://[::1%1]:9200/")] + [InlineData("http://[::1%25lo]:9200/")] + public void Add_ScopedIPv6LiteralPrefix_ThrowsHttpListenerException(string uriPrefix) + { + using (var listener = new HttpListener()) + { + listener.Prefixes.Add(uriPrefix); + AssertRejectedHost(Assert.Throws(() => listener.Start())); + } + + using (var factory = new HttpListenerFactory()) + { + AssertRejectedHost(Assert.Throws(() => factory.GetListener().Prefixes.Add(uriPrefix))); + } + + static void AssertRejectedHost(HttpListenerException exception) + { + // The managed implementation rejects the host before it binds anything; a socket error code + // would mean the prefix got as far as binding. + if (Helpers.IsManagedImplementation) + { + Assert.Equal((int)HttpStatusCode.BadRequest, exception.ErrorCode); + } + } + } + [Theory] [InlineData("")] [InlineData("http")] diff --git a/src/libraries/System.Net.HttpListener/tests/System.Net.HttpListener.Tests.csproj b/src/libraries/System.Net.HttpListener/tests/System.Net.HttpListener.Tests.csproj index 1d15aa28106abb..006cd86ecad4c8 100644 --- a/src/libraries/System.Net.HttpListener/tests/System.Net.HttpListener.Tests.csproj +++ b/src/libraries/System.Net.HttpListener/tests/System.Net.HttpListener.Tests.csproj @@ -15,6 +15,7 @@ +