From 35c10e35fa2037a6d7251b16a3cff088aef00fd7 Mon Sep 17 00:00:00 2001 From: Peter Shrosbree Date: Fri, 16 Oct 2026 02:15:40 -0700 Subject: [PATCH] Accept IPv6 literal hosts in the managed HttpListener On Linux and macOS, adding a prefix such as http://[::1]:8080/ makes Start throw HttpListenerException "Invalid port in prefix", while http.sys on Windows accepts it. The managed implementation finds the port by searching for the first ':' after the scheme, and for an IPv6 literal, that is a colon inside the brackets. The same search also splits IPv6 request hosts at a colon inside the brackets, so a request with "Host: [::1]:8080" gets 400 Bad Request before it reaches the application. The same split also breaks absolute-form requests for [::1] and HTTP/1.0 requests without a Host header that arrive over IPv6. For a host that starts with '[', find the port separator by looking for the first ':' after the closing ']'. Apply this rule in HttpEndPointManager.AddPrefixInternal, ListenerPrefix.Parse and HttpListenerRequest.FinishInitialization. Hosts that do not start with '[' are split as before, and the port checks are unchanged. A malformed bracketed host such as [::1]x can now be reported as an invalid host rather than an invalid port; both are 400. ListenerPrefix now normalizes IPv6 literals to match Uri.Host, so a prefix containing [0:0:0:0:0:0:0:1] matches a request for [::1]. Scope IDs remain unsupported and now report an invalid-host error. The [::] literal is now rejected with error 50, like 0.0.0.0, instead of 400. Binding needed no change: Dns.GetHostAddresses returns a literal such as "[::1]" without a lookup, so the listener binds to ::1. This change does not make localhost listen on both address families on Linux, as it does on Windows. Add tests for IPv6 loopback prefixes and requests, and for prefixes that must still be rejected: bad ports, an unclosed '[' and scoped literals. The full System.Net.HttpListener.Tests project passes in Release on linux-x64 and osx-arm64; on windows-x64 the new tests pass, and the one failing test fails on main too. Fixes #34399 --- .../System/Net/Managed/HttpEndPointManager.cs | 8 +- .../Managed/HttpListenerRequest.Managed.cs | 2 +- .../src/System/Net/Managed/ListenerPrefix.cs | 23 +++++- .../tests/GetContextHelper.cs | 25 ++++++ .../tests/HttpListenerIPv6Tests.cs | 81 +++++++++++++++++++ .../HttpListenerPrefixCollectionTests.cs | 38 +++++++++ .../System.Net.HttpListener.Tests.csproj | 1 + 7 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 src/libraries/System.Net.HttpListener/tests/HttpListenerIPv6Tests.cs 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 @@ +