-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Fix ipv6 address format in Host header #28578
Conversation
using (HttpClientHandler handler = CreateHttpClientHandler()) | ||
using (var client = new HttpClient(handler)) | ||
{ | ||
handler.Proxy = new WebProxy(proxyUri); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clever
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually you taught me this. : )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heh, ok, at least I'm consistent :)
[Fact] | ||
public async Task GetAsync_IPv6AddressInHostHeader_CorrectlyFormatted() | ||
{ | ||
string host = "[::1234]:8080"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you make the test a theory and validate with and without a port?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure.
@@ -269,7 +269,8 @@ private void ConsumeFromRemainingBuffer(int bytesToConsume) | |||
else | |||
{ | |||
Debug.Assert(_pool.UsingProxy); | |||
await WriteAsciiStringAsync(uri.IdnHost).ConfigureAwait(false); | |||
await WriteAsciiStringAsync(uri.HostNameType == UriHostNameType.IPv6 ? | |||
"[" + uri.IdnHost + "]" : uri.IdnHost).ConfigureAwait(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about using string interpolation instead of string concatenation manually i.e. $"[{uri.IdnHost}]"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
String interpolation would be more expensive here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does string interpolation has significant performance difference than string concatenation. I believe, string interpolation has better readability.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case it'll compile down to a call to string.Concat:
string.Concat("[", uri.IdnHost, "]");
which will incur just a single allocation for the destination string, with the data from each constituent string blitted into it. In contrast, the string interpolation would compile down to the equivalent of:
string.Format("[{0}]", new object[] { uri.IdnHost });
and would thus incur at least the destination string and an object array allocation, plus the work that has to be done inside of Format, which involves a StringBuilder.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another useful note on string.Concat vs. string.Format #8025 (comment) (I want "bookmark favorite comment" feature in GitHub)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stephentoub : Thank you for explaining it with implementation side. @kasper3 : Thank you for commenting with interesting thread.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we're now using uri.Host in the GetConnectionKey path, seems like it would make sense to use it here as well for consistency and to avoid the extra alloc (maybe?).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, will change and add comment as well.
public async Task GetAsync_IPv6AddressInHostHeader_CorrectlyFormatted() | ||
[Theory] | ||
[InlineData("http://", "[::1234]")] | ||
[InlineData("http://", "[::1234]:8080")] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: the scheme doesn't need to be parameterized
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will fix.
I was trying to add test data for |
[Theory] | ||
[InlineData("http://", "[::1234]")] | ||
[InlineData("http://", "[::1234]:8080")] | ||
public async Task GetAsync_IPv6AddressInHostHeader_CorrectlyFormatted(string scheme, string host) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume this test fails without your product change? Just checking.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without product change:
Assert.Contains() Failure
Not found: Host: [::1234]
In value: List<String> ["GET http://::1234/ HTTP/1.1", "Host: ::1234"]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only semi-related, is the Uri we're sending to the proxy correctly formatted, or does it also need [] around the IPv6 address?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On WinHttpHandler, we will append [] around the IPv6 address.
"GET http://[::1234]/ HTTP/1.1", "Proxy-Connection: Keep-Alive", "Host: [::1234]"
On SocketsHttpHandler, currently we don't:
"GET http://::1234/ HTTP/1.1", "Host: [::1234]"
Per RFC (https://tools.ietf.org/html/rfc3986#section-3.2.2), I think we should do that for the URI we are sending as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found another issue that for SocketsHttpHandler, the port number is missing in uri sent to the proxy as well.
Opened #28609 to track this.
To provide more confidence, by stepping into the code, I can see in |
@@ -269,7 +269,8 @@ private void ConsumeFromRemainingBuffer(int bytesToConsume) | |||
else | |||
{ | |||
Debug.Assert(_pool.UsingProxy); | |||
await WriteAsciiStringAsync(uri.IdnHost).ConfigureAwait(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about the if block above? Don't we need to fix that case as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we need to fix that as well. The fix there is more involved because it needs to happen as part of the construction of the connection key -- basically we should detect that the hostname is an IPv6 address when constructing the key, and construct the hostname using [].
You won't be able to test this using a proxy as you're doing here. But you should at least be able to test against IPv6Loopback. There's already a test for IP address based Uris -- GetAsync_IPBasedUri_Success. Unfortunately it's not validating the Host header. It should be modified to validate the Host header. And it would be nice to add https cases to that test as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for pointing this out! I will make a fix and add tests to it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix the above if
by changing GetConnectionKey
method.
We still need to special case this else
statement for Proxy
case, because for this case, we pass null
as host, thus _pool.HostHeaderValueBytes == null
, and here is the only place to append []
That's because those schemes will try to tunnel through your proxy. |
New commit explanation: Fix the IPv6 missing |
@dotnet-bot test Linux x64 Release Build CI hang. |
@@ -140,11 +140,12 @@ private static string ParseHostNameFromHeader(string hostHeader) | |||
private static HttpConnectionKey GetConnectionKey(HttpRequestMessage request, Uri proxyUri, bool isProxyConnect) | |||
{ | |||
Uri uri = request.RequestUri; | |||
string host = uri.HostNameType == UriHostNameType.IPv6 ? "[" + uri.IdnHost + "]" : uri.IdnHost; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is adding an allocation for every request using an IPv6 address as a host name. Is that necessary? Since the resulting HttpConnectionKey is used to choose the pool to use, can't this be cached on the pool instead? And if not, can we skip doing this if proxyUri == null?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't this be cached on the pool instead?
I think it's possible. I didn't do that in the first place, because we will need a helper logic to determine if the address we passed in is IPv6 or not (we pass in string, not uri), and I thought the helper logic could result in additional allocation. I will try to explore this option, if not able to come up with efficient way, we may need to try the second appraoch -> skip doing this if proxyUri == null.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IPv6 host names are unusual, saving a few extra allocations for them is not a high priority.
@stephentoub Do you think this change will result in smaller allocation? |
@@ -117,12 +117,15 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK | |||
|
|||
if (_host != null) | |||
{ | |||
bool isHostTypeIPv6 = _host.Split(':').Length - 1 > 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should it be just _host.Contains(':')
? Why does it check for 2+ :
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A valid IPv6 address contains at least two :
, so I check it for two times.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Split allocates unnecessarily - even if just per ConnectionPool, what about using IndexOf
on the string twice?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if it's performance critical, then I think more efficient way is:
var count = 0;
var isHostTypeIPv6 = false;
foreach (char c in _host)
{
if (c == ':' && ++count > 1)
{
isHostTypeIPv6 = true;
break;
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider host
is not a long string, IndexOf
can make the code cleaner.
from useSsl in new[] { true, false } | ||
select new object[] { address, useSsl }; | ||
|
||
[OuterLoop] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add to outerloop since the execution for this test is long, will cause Linux catastrophic failure in innerloop run.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the text execution long?
Why does it cause catastrophic failure on Linux?
This seems like a straightforward test, what am I missing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Linux catastrophic failure confused me a lot as well. Here is my observation:
- Before adding this test, the CI is green.
- After I added the test, it will consistently (every time ) cause catastrophic failure on Centos, Fedora, RedHat, and Ubuntu 1804, while passing on other distros. No matter I put it in innerloop/outerloop.
- After I disable the test on CurlHandler, no catastrophic failure with
System.Net.Http
. (There is one withSystem.Runtime.Extensions.Tests
, I think it's totally unrelated).
Why is the text execution long?
That's my guess. The execution log didn't show any test failure. So I doubt the execution failed is because of hang, then timeout.
Why does it cause catastrophic failure on Linux?
The test consistently cause catastrophic failure on Centos, Fedora, RedHat, and Ubuntu 1804, while passing on other distros. I think it could be (very likely) different CurlHandler version causing the issue. (Since Windows run is green every time).
To summary: I think it's a strange issue with CurlHandler causing the catastrophic failure. To unblock the PR, I will open a new issue to tract this, and disable the test against CurlHandler.
@@ -117,12 +117,16 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK | |||
|
|||
if (_host != null) | |||
{ | |||
int posColon = -1; | |||
bool isHostTypeIPv6 = (-1 != (posColon = _host.IndexOf(':')) && -1 != (posColon = _host.IndexOf(':', posColon))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't the second _host.IndexOf always going to succeed, because you're passing in posColon rather than posColon+1? Seems like we need some more tests, too, to catch this kind of thing.
Is it true that every IPv6 address contains at least two colons, and that if an address contains two colons it must be an IPv6 address? If so, with the aforementioned change, the logic seems fine.
If it's not true, a better approach might be to augment the HttpConnectionPool ctor to accept a "Uri exemplarUri", then at the ctor call site pass in the request.Uri, and here use Uri.HostNameType instead of doing this check manually.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, every ipv6 contains at least two colons and nothing else does.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, then with the index fix, this looks good, e.g.
bool isHostTypeIPv6 = (posColon = _host.IndexOf(':')) != -1 && _host.IndexOf(':', posColon+1) != -1;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will fix.
The new test I added: I will disable the test against Linux, to see if this is the issue. |
@dotnet-bot test Outerloop Windows x64 Debug Build |
int posColon = -1; | ||
bool isHostTypeIPv6 = (posColon = _host.IndexOf(':')) != -1 && _host.IndexOf(':', posColon+1) != -1; | ||
string hostAddress = isHostTypeIPv6 ? "[" + _host + "]" : _host; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we just use Uri.CheckHostName here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternatively, can we just go back to checking for HostNameType == IPv6 in GetConnectionKey, but use uri.Host instead of uri.IdnHost in that case? I think that will return the address with [], right?
I'm kinda queasy about doing our own parsing here. Uri does all sorts of stuff for us already, let's just use it if possible instead of trying to duplicate the logic ourselves.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will also point out that retrieving parts of the Uri (e.g. hostname) is already a bit of a perf bottleneck. Uri.IdnHost is allocating a string. I believe we also end up allocating later when we retrieve path/query etc.
We should look at optimizing this better, but not for 2.1.
In the meantime let's just do something simple and correct, and we can optimize interaction with Uri in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we just go back to checking for HostNameType == IPv6 in GetConnectionKey, but use uri.Host instead of uri.IdnHost in that case?
WinHttp clearly documented that it will only accept Punycode host name. Changing to uri.Host may bring regression for SocketsHttpHandler, and I think that's risky for 2.1.
I think that will return the address with [], right?
Yes, uri.Host will return the address with [].
I'm kinda queasy about doing our own parsing here.
I will change to Uri.CheckHostName
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WinHttp clearly documented that it will only accept Punycode host name. Changing to uri.Host may bring regression for SocketsHttpHandler, and I think that's risky for 2.1.
I'm not suggesting we change it in all cases. Obviously that would break Punycode handling. Just in the case where it's an IP address.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that should work. I will push a new commit.
|
||
if (isProxyConnect) | ||
{ | ||
Debug.Assert(uri == proxyUri); | ||
return new HttpConnectionKey(HttpConnectionKind.ProxyConnect, uri.IdnHost, uri.Port, null, proxyUri); | ||
return new HttpConnectionKey(HttpConnectionKind.ProxyConnect, isIPv6Address ? uri.Host: uri.IdnHost, uri.Port, null, proxyUri); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: space before colon in conditional expression, here and below
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops T.T, will fix.
@@ -140,11 +140,12 @@ private static string ParseHostNameFromHeader(string hostHeader) | |||
private static HttpConnectionKey GetConnectionKey(HttpRequestMessage request, Uri proxyUri, bool isProxyConnect) | |||
{ | |||
Uri uri = request.RequestUri; | |||
bool isIPv6Address = uri.HostNameType == UriHostNameType.IPv6; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should add a comment here, something like:
// If the hostname is an IPv6 address, uri.IdnHost will return the address without enclosing [].
// In this case, use uri.Host instead, which will correctly enclose with [].
// Note we don't need punycode encoding if it's an IP address, so using uri.Host is fine.
{ | ||
if (useSsl) | ||
{ | ||
handler.ServerCertificateCustomValidationCallback = delegate { return true; }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mentioned in a separate issue that you were seeing this test hang with CurlHandler on some OSes. Have you tried using HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
here instead of delegate { return true; }
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No I didn't. I will try it and enable the test to see if this can resolve the hang.
if (useSsl) | ||
{ | ||
handler.ServerCertificateCustomValidationCallback = delegate { return true; }; | ||
handler.ClientCertificateOptions = ClientCertificateOption.Automatic; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this setting of ClientCertificateOptions needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will remove.
Changing to @dotnet-bot test NETFX x86 Release Build |
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; | ||
handler.ServerCertificateCustomValidationCallback = | ||
#if netcoreapp | ||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use TestHelper.AllowAllCertificates
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will change.
Unrelated failure. @dotnet-bot test Linux x64 Release Build |
* fix ipv6 format in host header * address feedback * address feedback * fix common case * reduce allocation * address feedback * fix indexof issue * disable on curlhandler * revert to simple fix * address feedback and add comment * address test issue, enable on curlhandler * fix framework failure * address feedback Commit migrated from dotnet/corefx@24889e2
Per discussion in #28557. If this change looks ok, I will fix the WinHttp as well.
Fix: #28557 (moved to dotnet/runtime#25661)