From 225e438c31abb529fbc7a1d8ee8ca3f68686511b Mon Sep 17 00:00:00 2001 From: Carol Wang Date: Tue, 11 Feb 2025 03:07:36 +0000 Subject: [PATCH 1/4] Change WebSocket handshake implementation to align with the .NET approach. --- .../src/Resources/Strings.resx | 15 + .../src/Resources/xlf/Strings.cs.xlf | 25 ++ .../src/Resources/xlf/Strings.de.xlf | 25 ++ .../src/Resources/xlf/Strings.es.xlf | 25 ++ .../src/Resources/xlf/Strings.fr.xlf | 25 ++ .../src/Resources/xlf/Strings.it.xlf | 25 ++ .../src/Resources/xlf/Strings.ja.xlf | 25 ++ .../src/Resources/xlf/Strings.ko.xlf | 25 ++ .../src/Resources/xlf/Strings.pl.xlf | 25 ++ .../src/Resources/xlf/Strings.pt-BR.xlf | 25 ++ .../src/Resources/xlf/Strings.ru.xlf | 25 ++ .../src/Resources/xlf/Strings.tr.xlf | 25 ++ .../src/Resources/xlf/Strings.zh-Hans.xlf | 25 ++ .../src/Resources/xlf/Strings.zh-Hant.xlf | 25 ++ ...tWebSocketTransportDuplexSessionChannel.cs | 332 ++++++++++++++---- .../Channels/HttpKnownHeaderNames.cs | 93 +++++ 16 files changed, 706 insertions(+), 59 deletions(-) create mode 100644 src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs diff --git a/src/System.ServiceModel.Http/src/Resources/Strings.resx b/src/System.ServiceModel.Http/src/Resources/Strings.resx index 6ac5023c405..1b46e7b8497 100644 --- a/src/System.ServiceModel.Http/src/Resources/Strings.resx +++ b/src/System.ServiceModel.Http/src/Resources/Strings.resx @@ -363,4 +363,19 @@ The subprotocol '{0}' was not requested by the client. The client requested the following subprotocol(s): '{1}'. + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + Unable to connect to the remote server + + + The server returned status code '{0}' when status code '{1}' was expected. + + + The server's response was missing the required header '{0}'. + + + The '{0}' header value '{1}' is invalid. + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.cs.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.cs.xlf index 3e5ed772ca5..af25a85bbca 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.cs.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.cs.xlf @@ -412,6 +412,31 @@ Server nepřijal žádost o připojení. Verze protokolu WebSocket na straně klienta se pravděpodobně neshoduje s nastavením na straně serveru ({0}). + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.de.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.de.xlf index 96ae0eef208..3be24dd6f31 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.de.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.de.xlf @@ -412,6 +412,31 @@ Der Server hat die Verbindungsanforderung nicht akzeptiert. Möglicherweise stimmt die Version des WebSocket-Protokolls auf dem Client nicht mit der auf dem Server ("{0}") überein. + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.es.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.es.xlf index 3728c8dd124..2a9b99a7526 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.es.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.es.xlf @@ -412,6 +412,31 @@ El servidor no aceptó la solicitud de conexión. Es posible que la versión del subprotocolo WebSocket de su cliente no coincida con el del servidor ("{0}"). + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.fr.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.fr.xlf index 5f48d297549..4155c7c47e5 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.fr.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.fr.xlf @@ -412,6 +412,31 @@ Le serveur a refusé la demande de connexion. Il est possible que la version du protocole WebSocket sur votre client ne corresponde pas à la version située sur le serveur ('{0}'). + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.it.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.it.xlf index 49076ebb27d..9fd70dcb544 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.it.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.it.xlf @@ -412,6 +412,31 @@ Il server non ha accettato la richiesta di connessione. È possibile che la versione del protocollo WebSocket nel client non corrisponda a quella nel server ('{0}'). + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ja.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ja.xlf index 56b1a2f0d70..25dd0fe8a2b 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ja.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ja.xlf @@ -412,6 +412,31 @@ サーバーが接続要求を受け入れませんでした。クライアントの WebSocket プロトコルのバージョンが、サーバー ('{0}') のものと一致していない可能性があります。 + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ko.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ko.xlf index 2c4bb87e9d6..e02c540f62e 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ko.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ko.xlf @@ -412,6 +412,31 @@ 서버에서 연결 요청을 수락하지 않았습니다. 클라이언트의 WebSocket 프로토콜 버전이 서버의 프로토콜 버전('{0}')과 일치하지 않을 수 있습니다. + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pl.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pl.xlf index 96297b597ba..021a695d8fd 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pl.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pl.xlf @@ -412,6 +412,31 @@ Serwer nie zaakceptował żądania połączenia. Być może wersja protokołu WebSocket używana przez klienta jest niezgodna z wersją używaną przez serwer („{0}”). + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pt-BR.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pt-BR.xlf index 9d046cd6329..d75f2648162 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pt-BR.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.pt-BR.xlf @@ -412,6 +412,31 @@ O servidor não aceitou a solicitação de conexão. É possível que a versão do protocolo WebSocket no cliente não corresponda à versão no servidor ('{0}'). + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ru.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ru.xlf index 66ddad72aca..3ac453779c8 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ru.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.ru.xlf @@ -412,6 +412,31 @@ Сервер не принял запрос на подключение. Возможно, версия протокола WebSocket на клиенте не согласуется с таковой на сервере ("{0}"). + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.tr.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.tr.xlf index fcb5d5a876f..38246880824 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.tr.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.tr.xlf @@ -412,6 +412,31 @@ Sunucu, bağlantı isteğini kabul etmedi. İstemciniz üzerindeki WebSocket protokol sürümü, sunucudaki ('{0}') ile eşleşmiyor olabilir. + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hans.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hans.xlf index 3cf60175219..70282e66671 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hans.xlf @@ -412,6 +412,31 @@ 服务器不接受连接请求。有可能是因为客户端上 WebSocket 协议的版本与服务器上该协议的版本({0})不匹配。 + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hant.xlf b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hant.xlf index 71206117018..be5d423c14a 100644 --- a/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/System.ServiceModel.Http/src/Resources/xlf/Strings.zh-Hant.xlf @@ -412,6 +412,31 @@ 伺服器未接受連線要求。可能是用戶端的 WebSocket 通訊協定版本與伺服器的版本 ('{0}') 不符。 + + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + The WebSocket client request requested '{0}' protocol(s), but server is only accepting '{1}' protocol(s). + + + + The server returned status code '{0}' when status code '{1}' was expected. + The server returned status code '{0}' when status code '{1}' was expected. + + + + The '{0}' header value '{1}' is invalid. + The '{0}' header value '{1}' is invalid. + + + + The server's response was missing the required header '{0}'. + The server's response was missing the required header '{0}'. + + + + Unable to connect to the remote server + Unable to connect to the remote server + + \ No newline at end of file diff --git a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs index 90ff387bb59..6a10a20c666 100644 --- a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs +++ b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs @@ -4,17 +4,22 @@ using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.IdentityModel.Selectors; using System.IdentityModel.Tokens; +using System.IO; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Net.Security; using System.Net.WebSockets; using System.Runtime; using System.Security.Cryptography.X509Certificates; using System.Security.Principal; using System.ServiceModel.Security.Tokens; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -57,7 +62,10 @@ protected override void OnOpen(TimeSpan timeout) protected internal override async Task OnOpenAsync(TimeSpan timeout) { TimeoutHelper helper = new TimeoutHelper(timeout); - + bool disposeInvoker = false; + (HttpMessageInvoker invoker, disposeInvoker) = await SetupInvoker(helper.RemainingTime()); + HttpResponseMessage response = null; + bool disposeResponse = false; bool success = false; try { @@ -70,11 +78,134 @@ protected internal override async Task OnOpenAsync(TimeSpan timeout) try { - var clientWebSocket = new ClientWebSocket(); - await ConfigureClientWebSocketAsync(clientWebSocket, helper.RemainingTime()); - await clientWebSocket.ConnectAsync(Via, await helper.GetCancellationTokenAsync()); - ValidateWebSocketConnection(clientWebSocket); - WebSocket = clientWebSocket; + //old implementation: + //var clientWebSocket = new ClientWebSocket(); + //await ConfigureClientWebSocketAsync(clientWebSocket, helper.RemainingTime()); + //await clientWebSocket.ConnectAsync(Via, await helper.GetCancellationTokenAsync()); + //ValidateWebSocketConnection(clientWebSocket); + //WebSocket = clientWebSocket; + + /* new implementation: + 1. Create a SocketsHttpHandler, set certificate, certificate validation callback, and credentials. Wrap in an HttpMessageInvoker. + 2. Create an HttpRequestMessage. Set the required WebSocket headers, including any sub protocols, set the service url etc. + 3. Send the request using the invoker + 4. Validate the response headers, including upgrade status code, websocket specific headers, sub protocol. + 5. Fetch the content stream from the response content + 6. Wrap the content stream in a websocket + */ + + try + { + while (true) + { + try + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, Via) { Version = HttpVersion.Version11 }; + //if (options._requestHeaders?.Count > 0) // use field to avoid lazily initializing the collection + //{ + // foreach (string key in options.RequestHeaders) + // { + // request.Headers.TryAddWithoutValidation(key, options.RequestHeaders[key]); + // } + //} + + // These headers were added for WCF specific handshake to avoid encoder or transfermode mismatch between client and server. + // For BinaryMessageEncoder, since we are using a sessionful channel for websocket, the encoder is actually different when + // we are using Buffered or Stramed transfermode. So we need an extra header to identify the transfermode we are using, just + // to make people a little bit easier to diagnose these mismatch issues. + if (_channelFactory.MessageVersion != MessageVersion.None) + { + request.Headers.TryAddWithoutValidation(WebSocketTransportSettings.SoapContentTypeHeader, _channelFactory.WebSocketSoapContentType); + + if (_channelFactory.MessageEncoderFactory is BinaryMessageEncoderFactory) + { + request.Headers.TryAddWithoutValidation(WebSocketTransportSettings.BinaryEncoderTransferModeHeader, _channelFactory.TransferMode.ToString()); + } + } + + string secValue = AddWebSocketHeaders(request); + + Task sendTask = invoker is HttpClient client + ? client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) + : invoker.SendAsync(request, CancellationToken.None); + response = await sendTask.ConfigureAwait(false); + + ValidateResponse(response, secValue); + break; + } + catch (HttpRequestException ex) when (ex.HttpRequestError == HttpRequestError.ExtendedConnectNotSupported || ex.Data.Contains("HTTP2_ENABLED")) + { + } + } + + // The SecWebSocketProtocol header is optional. We should only get it with a non-empty value if we requested subprotocols, + // and then it must only be one of the ones we requested. If we got a subprotocol other than one we requested (or if we + // already got one in a previous header), fail. Otherwise, track which one we got. + string subprotocol = null; + if (response.Headers.TryGetValues(HttpKnownHeaderNames.SecWebSocketProtocol, out IEnumerable subprotocolEnumerableValues)) + { + Debug.Assert(subprotocolEnumerableValues is string[]); + string[] subprotocolArray = (string[])subprotocolEnumerableValues; + if (subprotocolArray.Length > 0 && !string.IsNullOrEmpty(subprotocolArray[0])) + { + if (WebSocketSettings.SubProtocol is not null) + { + if (WebSocketSettings.SubProtocol.Equals(subprotocolArray[0], StringComparison.OrdinalIgnoreCase)) + { + subprotocol = WebSocketSettings.SubProtocol; + } + } + + if (subprotocol == null) + { + throw new WebSocketException( + WebSocketError.UnsupportedProtocol, + SR.Format(SR.net_WebSockets_AcceptUnsupportedProtocol, WebSocketSettings.SubProtocol, string.Join(", ", subprotocolArray))); + } + } + } + + // Get the response stream and wrap it in a web socket. + Stream connectedStream = response.Content.ReadAsStream(); + Debug.Assert(connectedStream.CanWrite); + Debug.Assert(connectedStream.CanRead); + WebSocket = WebSocket.CreateFromStream(connectedStream, new WebSocketCreationOptions + { + IsServer = false, + SubProtocol = subprotocol, + KeepAliveInterval = this.WebSocketSettings.KeepAliveInterval, + //???KeepAliveTimeout = options.KeepAliveTimeout + //???DangerousDeflateOptions = negotiatedDeflateOptions + }); + } + catch (Exception exc) + { + Abort(); + disposeResponse = true; + + if (exc is WebSocketException || exc is OperationCanceledException) + { + throw; + } + + throw new WebSocketException(WebSocketError.Faulted, SR.net_webstatus_ConnectFailure, exc); + } + finally + { + if (response is not null) + { + if (disposeResponse) + { + response.Dispose(); + } + } + + // Disposing the invoker will not affect any active stream wrapped in the WebSocket. + if (disposeInvoker) + { + invoker?.Dispose(); + } + } } finally { @@ -124,95 +255,122 @@ protected internal override async Task OnOpenAsync(TimeSpan timeout) } } - private void ValidateWebSocketConnection(ClientWebSocket clientWebSocket) + private void ValidateResponse(HttpResponseMessage response, string secValue) { - string requested = WebSocketSettings.SubProtocol; - string obtained = clientWebSocket.SubProtocol; - if (!(requested == null ? string.IsNullOrWhiteSpace(obtained) : requested.Equals(obtained, StringComparison.OrdinalIgnoreCase))) + Debug.Assert(response.Version == HttpVersion.Version11 || response.Version == HttpVersion.Version20); + + if (response.Version == HttpVersion.Version11) { - clientWebSocket.Dispose(); - throw FxTrace.Exception.AsError(new InvalidOperationException(SR.Format(SR.WebSocketInvalidProtocolNotInClientList, obtained, requested))); + if (response.StatusCode != HttpStatusCode.SwitchingProtocols) + { + throw new WebSocketException(WebSocketError.NotAWebSocket, SR.Format(SR.net_WebSockets_ConnectStatusExpected, (int)response.StatusCode, (int)HttpStatusCode.SwitchingProtocols)); + } + + Debug.Assert(secValue != null); + + // The Connection, Upgrade, and SecWebSocketAccept headers are required and with specific values. + ValidateHeader(response.Headers, HttpKnownHeaderNames.Connection, "Upgrade"); + ValidateHeader(response.Headers, HttpKnownHeaderNames.Upgrade, "websocket"); + ValidateHeader(response.Headers, HttpKnownHeaderNames.SecWebSocketAccept, secValue); } - } - private async Task ConfigureClientWebSocketAsync(ClientWebSocket clientWebSocket, TimeSpan timeout) - { - TimeoutHelper helper = new TimeoutHelper(timeout); - ChannelParameterCollection channelParameterCollection = new ChannelParameterCollection(); - if (HttpChannelFactory.MapIdentity(RemoteAddress, _channelFactory.AuthenticationScheme)) + if (response.Content is null) { - clientWebSocket.Options.SetRequestHeader("Host", HttpTransportSecurityHelpers.GetIdentityHostHeader(RemoteAddress)); + throw new WebSocketException(WebSocketError.ConnectionClosedPrematurely); } + } - (_webRequestTokenProvider, _webRequestProxyTokenProvider) = - await _channelFactory.CreateAndOpenTokenProvidersAsync( - RemoteAddress, - Via, - channelParameterCollection, - helper.RemainingTime()); - - SecurityTokenContainer clientCertificateToken = null; - if (_channelFactory is HttpsChannelFactory httpsChannelFactory && httpsChannelFactory.RequireClientCertificate) + private static void ValidateHeader(HttpHeaders headers, string name, string expectedValue) + { + if (headers.NonValidated.TryGetValues(name, out HeaderStringValues hsv)) { - SecurityTokenProvider certificateProvider = await httpsChannelFactory.CreateAndOpenCertificateTokenProviderAsync(RemoteAddress, Via, channelParameterCollection, helper.RemainingTime()); - clientCertificateToken = await httpsChannelFactory.GetCertificateSecurityTokenAsync(certificateProvider, RemoteAddress, Via, channelParameterCollection, helper); - if (clientCertificateToken != null) + if (hsv.Count == 1) { - X509SecurityToken x509Token = (X509SecurityToken)clientCertificateToken.Token; - clientWebSocket.Options.ClientCertificates.Add(x509Token.Certificate); + foreach (string value in hsv) + { + if (string.Equals(value, expectedValue, StringComparison.OrdinalIgnoreCase)) + { + return; + } + break; + } } - if (httpsChannelFactory.WebSocketCertificateCallback != null) - { - clientWebSocket.Options.RemoteCertificateValidationCallback = httpsChannelFactory.WebSocketCertificateCallback; - } + throw new WebSocketException(WebSocketError.HeaderError, SR.Format(SR.net_WebSockets_InvalidResponseHeader, name, hsv)); } - if (WebSocketSettings.SubProtocol != null) + throw new WebSocketException(WebSocketError.Faulted, SR.Format(SR.net_WebSockets_MissingResponseHeader, name)); + } + + private async Task<(HttpMessageInvoker invoker, bool disposeInvoker)> SetupInvoker(TimeSpan timeout) + { + TimeoutHelper helper = new TimeoutHelper(timeout); + ChannelParameterCollection channelParameterCollection = new ChannelParameterCollection(); + bool disposeInvoker = true; + var handler = new SocketsHttpHandler(); + handler.PooledConnectionLifetime = TimeSpan.Zero; + if (_channelFactory.AllowCookies) { - clientWebSocket.Options.AddSubProtocol(WebSocketSettings.SubProtocol); + var cookieContainerManager = _channelFactory.GetHttpCookieContainerManager(); + handler.CookieContainer = cookieContainerManager.CookieContainer; + handler.UseCookies = cookieContainerManager.CookieContainer != null; } - // These headers were added for WCF specific handshake to avoid encoder or transfermode mismatch between client and server. - // For BinaryMessageEncoder, since we are using a sessionful channel for websocket, the encoder is actually different when - // we are using Buffered or Stramed transfermode. So we need an extra header to identify the transfermode we are using, just - // to make people a little bit easier to diagnose these mismatch issues. - if (_channelFactory.MessageVersion != MessageVersion.None) + //configure handler.SslOptions + SecurityTokenContainer clientCertificateToken = null; + if (_channelFactory is HttpsChannelFactory httpsChannelFactory) { - clientWebSocket.Options.SetRequestHeader(WebSocketTransportSettings.SoapContentTypeHeader, _channelFactory.WebSocketSoapContentType); - - if (_channelFactory.MessageEncoderFactory is BinaryMessageEncoderFactory) + if(httpsChannelFactory.RequireClientCertificate) + { + SecurityTokenProvider certificateProvider = await httpsChannelFactory.CreateAndOpenCertificateTokenProviderAsync(RemoteAddress, Via, channelParameterCollection, helper.RemainingTime()); + clientCertificateToken = await httpsChannelFactory.GetCertificateSecurityTokenAsync(certificateProvider, RemoteAddress, Via, channelParameterCollection, helper); + if (clientCertificateToken != null) + { + X509SecurityToken x509Token = (X509SecurityToken)clientCertificateToken.Token; + Debug.Assert(handler.SslOptions.ClientCertificates == null); + handler.SslOptions.ClientCertificates = new X509Certificate2Collection + { + x509Token.Certificate + }; + } + } + + if (httpsChannelFactory.WebSocketCertificateCallback != null) { - clientWebSocket.Options.SetRequestHeader(WebSocketTransportSettings.BinaryEncoderTransferModeHeader, _channelFactory.TransferMode.ToString()); + handler.SslOptions.RemoteCertificateValidationCallback = httpsChannelFactory.WebSocketCertificateCallback; } } + //configure handler.Proxy (NetworkCredential credential, TokenImpersonationLevel impersonationLevel, AuthenticationLevel authenticationLevel) = - await HttpChannelUtilities.GetCredentialAsync(_channelFactory.AuthenticationScheme, _webRequestTokenProvider, timeout); - + await HttpChannelUtilities.GetCredentialAsync(_channelFactory.AuthenticationScheme, _webRequestTokenProvider, timeout); if (_channelFactory.Proxy != null) { - clientWebSocket.Options.Proxy = _channelFactory.Proxy; + handler.Proxy = _channelFactory.Proxy; } else if (_channelFactory.ProxyFactory != null) { - clientWebSocket.Options.Proxy = await _channelFactory.ProxyFactory.CreateWebProxyAsync( + handler.Proxy = await _channelFactory.ProxyFactory.CreateWebProxyAsync( authenticationLevel, impersonationLevel, _webRequestProxyTokenProvider, helper.RemainingTime()); } + else + { + handler.UseProxy = false; + } + //configure handler.Credentials if (credential == CredentialCache.DefaultCredentials || credential == null) { if (_channelFactory.AuthenticationScheme != AuthenticationSchemes.Anonymous) { - clientWebSocket.Options.UseDefaultCredentials = true; + handler.Credentials = CredentialCache.DefaultCredentials; } } else { - clientWebSocket.Options.UseDefaultCredentials = false; CredentialCache credentials = new CredentialCache(); Uri credentialCacheUriPrefix = _channelFactory.GetCredentialCacheUriPrefix(Via); if (_channelFactory.AuthenticationScheme == AuthenticationSchemes.IntegratedWindowsAuthentication) @@ -228,16 +386,72 @@ await _channelFactory.CreateAndOpenTokenProvidersAsync( credential); } - clientWebSocket.Options.Credentials = credentials; + handler.Credentials = credentials; } - if (_channelFactory.AllowCookies) + return (new HttpMessageInvoker(handler), disposeInvoker); + } + + private string AddWebSocketHeaders(HttpRequestMessage request) + { + // always exact because we handle downgrade here + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + string secValue = null; + + if (request.Version == HttpVersion.Version11) { - var cookieContainerManager = _channelFactory.GetHttpCookieContainerManager(); - clientWebSocket.Options.Cookies = cookieContainerManager.CookieContainer; + // Create the security key and expected response, then build all of the request headers + KeyValuePair secKeyAndSecWebSocketAccept = CreateSecKeyAndSecWebSocketAccept(); + secValue = secKeyAndSecWebSocketAccept.Value; + request.Headers.TryAddWithoutValidation(HttpKnownHeaderNames.Connection, HttpKnownHeaderNames.Upgrade); + request.Headers.TryAddWithoutValidation(HttpKnownHeaderNames.Upgrade, "websocket"); + request.Headers.TryAddWithoutValidation(HttpKnownHeaderNames.SecWebSocketKey, secKeyAndSecWebSocketAccept.Key); + } + else if (request.Version == HttpVersion.Version20) + { + request.Headers.Protocol = "websocket"; + } + + request.Headers.TryAddWithoutValidation(HttpKnownHeaderNames.SecWebSocketVersion, "13"); + + if (WebSocketSettings.SubProtocol != null) + { + request.Headers.TryAddWithoutValidation(HttpKnownHeaderNames.SecWebSocketProtocol, WebSocketSettings.SubProtocol); } + + return secValue; + } + + /// + /// Creates a pair of a security key for sending in the Sec-WebSocket-Key header and + /// the associated response we expect to receive as the Sec-WebSocket-Accept header value. + /// + /// A key-value pair of the request header security key and expected response header value. + [SuppressMessage("Microsoft.Security", "CA5350", Justification = "Required by RFC6455")] + private static KeyValuePair CreateSecKeyAndSecWebSocketAccept() + { + // GUID appended by the server as part of the security key response. Defined in the RFC. + ReadOnlySpan wsServerGuidBytes = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"u8; + + Span bytes = stackalloc byte[24 /* Base64 guid length */ + wsServerGuidBytes.Length]; + + // Base64-encode a new Guid's bytes to get the security key + bool success = Guid.NewGuid().TryWriteBytes(bytes); + Debug.Assert(success); + string secKey = Convert.ToBase64String(bytes.Slice(0, 16 /*sizeof(Guid)*/)); + + // Get the corresponding ASCII bytes for seckey+wsServerGuidBytes + int encodedSecKeyLength = Encoding.ASCII.GetBytes(secKey, bytes); + wsServerGuidBytes.CopyTo(bytes.Slice(encodedSecKeyLength)); + + // Hash the seckey+wsServerGuidBytes bytes + System.Security.Cryptography.SHA1.TryHashData(bytes, bytes, out int bytesWritten); + Debug.Assert(bytesWritten == 20 /* SHA1 hash length */); - clientWebSocket.Options.KeepAliveInterval = _channelFactory.WebSocketSettings.KeepAliveInterval; + // Return the security key + the base64 encoded hashed bytes + return new KeyValuePair( + secKey, + Convert.ToBase64String(bytes.Slice(0, bytesWritten))); } protected override void OnCleanup() diff --git a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs new file mode 100644 index 00000000000..804035ec16b --- /dev/null +++ b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.ServiceModel.Channels +{ + // from https://source.dot.net/#System.Net.WebSockets.Client/src/libraries/Common/src/System/Net/HttpKnownHeaderNames.cs + internal static partial class HttpKnownHeaderNames + { + public const string Accept = "Accept"; + public const string AcceptCharset = "Accept-Charset"; + public const string AcceptEncoding = "Accept-Encoding"; + public const string AcceptLanguage = "Accept-Language"; + public const string AcceptPatch = "Accept-Patch"; + public const string AcceptRanges = "Accept-Ranges"; + public const string AccessControlAllowCredentials = "Access-Control-Allow-Credentials"; + public const string AccessControlAllowHeaders = "Access-Control-Allow-Headers"; + public const string AccessControlAllowMethods = "Access-Control-Allow-Methods"; + public const string AccessControlAllowOrigin = "Access-Control-Allow-Origin"; + public const string AccessControlExposeHeaders = "Access-Control-Expose-Headers"; + public const string AccessControlMaxAge = "Access-Control-Max-Age"; + public const string Age = "Age"; + public const string Allow = "Allow"; + public const string AltSvc = "Alt-Svc"; + public const string Authorization = "Authorization"; + public const string CacheControl = "Cache-Control"; + public const string Connection = "Connection"; + public const string ContentDisposition = "Content-Disposition"; + public const string ContentEncoding = "Content-Encoding"; + public const string ContentLanguage = "Content-Language"; + public const string ContentLength = "Content-Length"; + public const string ContentLocation = "Content-Location"; + public const string ContentMD5 = "Content-MD5"; + public const string ContentRange = "Content-Range"; + public const string ContentSecurityPolicy = "Content-Security-Policy"; + public const string ContentType = "Content-Type"; + public const string Cookie = "Cookie"; + public const string Cookie2 = "Cookie2"; + public const string Date = "Date"; + public const string ETag = "ETag"; + public const string Expect = "Expect"; + public const string Expires = "Expires"; + public const string From = "From"; + public const string Host = "Host"; + public const string IfMatch = "If-Match"; + public const string IfModifiedSince = "If-Modified-Since"; + public const string IfNoneMatch = "If-None-Match"; + public const string IfRange = "If-Range"; + public const string IfUnmodifiedSince = "If-Unmodified-Since"; + public const string KeepAlive = "Keep-Alive"; + public const string LastModified = "Last-Modified"; + public const string Link = "Link"; + public const string Location = "Location"; + public const string MaxForwards = "Max-Forwards"; + public const string Origin = "Origin"; + public const string P3P = "P3P"; + public const string Pragma = "Pragma"; + public const string ProxyAuthenticate = "Proxy-Authenticate"; + public const string ProxyAuthorization = "Proxy-Authorization"; + public const string ProxyConnection = "Proxy-Connection"; + public const string PublicKeyPins = "Public-Key-Pins"; + public const string Range = "Range"; + public const string Referer = "Referer"; // NB: The spelling-mistake "Referer" for "Referrer" must be matched. + public const string RetryAfter = "Retry-After"; + public const string SecWebSocketAccept = "Sec-WebSocket-Accept"; + public const string SecWebSocketExtensions = "Sec-WebSocket-Extensions"; + public const string SecWebSocketKey = "Sec-WebSocket-Key"; + public const string SecWebSocketProtocol = "Sec-WebSocket-Protocol"; + public const string SecWebSocketVersion = "Sec-WebSocket-Version"; + public const string Server = "Server"; + public const string SetCookie = "Set-Cookie"; + public const string SetCookie2 = "Set-Cookie2"; + public const string StrictTransportSecurity = "Strict-Transport-Security"; + public const string TE = "TE"; + public const string TSV = "TSV"; + public const string Trailer = "Trailer"; + public const string TransferEncoding = "Transfer-Encoding"; + public const string Upgrade = "Upgrade"; + public const string UpgradeInsecureRequests = "Upgrade-Insecure-Requests"; + public const string UserAgent = "User-Agent"; + public const string Vary = "Vary"; + public const string Via = "Via"; + public const string WWWAuthenticate = "WWW-Authenticate"; + public const string Warning = "Warning"; + public const string XAspNetVersion = "X-AspNet-Version"; + public const string XContentDuration = "X-Content-Duration"; + public const string XContentTypeOptions = "X-Content-Type-Options"; + public const string XFrameOptions = "X-Frame-Options"; + public const string XMSEdgeRef = "X-MSEdge-Ref"; + public const string XPoweredBy = "X-Powered-By"; + public const string XRequestID = "X-Request-ID"; + public const string XUACompatible = "X-UA-Compatible"; + } +} From 58b932cc2998ad26a97a8efc630a09a1097c7bd7 Mon Sep 17 00:00:00 2001 From: Carol Wang Date: Thu, 27 Feb 2025 02:04:13 +0000 Subject: [PATCH 2/4] Clean up. --- ...tWebSocketTransportDuplexSessionChannel.cs | 32 +++---------------- .../Channels/HttpKnownHeaderNames.cs | 1 - 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs index 6a10a20c666..7de85e7ea3d 100644 --- a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs +++ b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs @@ -78,22 +78,6 @@ protected internal override async Task OnOpenAsync(TimeSpan timeout) try { - //old implementation: - //var clientWebSocket = new ClientWebSocket(); - //await ConfigureClientWebSocketAsync(clientWebSocket, helper.RemainingTime()); - //await clientWebSocket.ConnectAsync(Via, await helper.GetCancellationTokenAsync()); - //ValidateWebSocketConnection(clientWebSocket); - //WebSocket = clientWebSocket; - - /* new implementation: - 1. Create a SocketsHttpHandler, set certificate, certificate validation callback, and credentials. Wrap in an HttpMessageInvoker. - 2. Create an HttpRequestMessage. Set the required WebSocket headers, including any sub protocols, set the service url etc. - 3. Send the request using the invoker - 4. Validate the response headers, including upgrade status code, websocket specific headers, sub protocol. - 5. Fetch the content stream from the response content - 6. Wrap the content stream in a websocket - */ - try { while (true) @@ -101,13 +85,6 @@ 6. Wrap the content stream in a websocket try { HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, Via) { Version = HttpVersion.Version11 }; - //if (options._requestHeaders?.Count > 0) // use field to avoid lazily initializing the collection - //{ - // foreach (string key in options.RequestHeaders) - // { - // request.Headers.TryAddWithoutValidation(key, options.RequestHeaders[key]); - // } - //} // These headers were added for WCF specific handshake to avoid encoder or transfermode mismatch between client and server. // For BinaryMessageEncoder, since we are using a sessionful channel for websocket, the encoder is actually different when @@ -320,7 +297,7 @@ private static void ValidateHeader(HttpHeaders headers, string name, string expe SecurityTokenContainer clientCertificateToken = null; if (_channelFactory is HttpsChannelFactory httpsChannelFactory) { - if(httpsChannelFactory.RequireClientCertificate) + if (httpsChannelFactory.RequireClientCertificate) { SecurityTokenProvider certificateProvider = await httpsChannelFactory.CreateAndOpenCertificateTokenProviderAsync(RemoteAddress, Via, channelParameterCollection, helper.RemainingTime()); clientCertificateToken = await httpsChannelFactory.GetCertificateSecurityTokenAsync(certificateProvider, RemoteAddress, Via, channelParameterCollection, helper); @@ -334,7 +311,7 @@ private static void ValidateHeader(HttpHeaders headers, string name, string expe }; } } - + //Fix for issue #5729: Removed the httpsChannelFactory.RequireClientCertificate condition from the following if statement. if (httpsChannelFactory.WebSocketCertificateCallback != null) { handler.SslOptions.RemoteCertificateValidationCallback = httpsChannelFactory.WebSocketCertificateCallback; @@ -343,7 +320,7 @@ private static void ValidateHeader(HttpHeaders headers, string name, string expe //configure handler.Proxy (NetworkCredential credential, TokenImpersonationLevel impersonationLevel, AuthenticationLevel authenticationLevel) = - await HttpChannelUtilities.GetCredentialAsync(_channelFactory.AuthenticationScheme, _webRequestTokenProvider, timeout); + await HttpChannelUtilities.GetCredentialAsync(_channelFactory.AuthenticationScheme, _webRequestTokenProvider, timeout); if (_channelFactory.Proxy != null) { handler.Proxy = _channelFactory.Proxy; @@ -418,7 +395,7 @@ private string AddWebSocketHeaders(HttpRequestMessage request) { request.Headers.TryAddWithoutValidation(HttpKnownHeaderNames.SecWebSocketProtocol, WebSocketSettings.SubProtocol); } - + return secValue; } @@ -502,4 +479,3 @@ private void CleanupTokenProviders() } } } - diff --git a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs index 804035ec16b..90829439203 100644 --- a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs +++ b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/HttpKnownHeaderNames.cs @@ -3,7 +3,6 @@ namespace System.ServiceModel.Channels { - // from https://source.dot.net/#System.Net.WebSockets.Client/src/libraries/Common/src/System/Net/HttpKnownHeaderNames.cs internal static partial class HttpKnownHeaderNames { public const string Accept = "Accept"; From 94e3931dcc3cfa4bde1d9ed443832a3afccbe7e7 Mon Sep 17 00:00:00 2001 From: Carol Wang Date: Mon, 15 Jun 2026 13:48:34 +0800 Subject: [PATCH 3/4] Address review feedback on WebSocket handshake rewrite Restore behavior that was lost when ClientWebSocket.ConnectAsync was replaced with the manual SocketsHttpHandler-based handshake: - Initialize _webRequestTokenProvider / _webRequestProxyTokenProvider via CreateAndOpenTokenProvidersAsync at the top of SetupInvoker; otherwise GetCredentialAsync and the proxy factory receive null providers, breaking Basic/Digest/Negotiate/NTLM client auth and authenticated proxy. - Set the Host request header via MapIdentity / GetIdentityHostHeader so EndpointIdentity-based Kerberos SPN/DNS/UPN scenarios continue to authenticate against the identity host instead of the URI host. - Pass a cancellation token derived from TimeoutHelper into SendAsync so OpenAsync(timeout) honors the user-supplied timeout instead of hanging on DNS/TCP/TLS/server stalls. - Stop setting handler.UseProxy = false in the no-explicit-proxy branch; leaving the SocketsHttpHandler default preserves system / default web proxy behavior, matching the prior ClientWebSocket implementation. - Remove the while(true) + HttpRequestError.ExtendedConnectNotSupported catch: the request is hard-coded to HTTP/1.1 with RequestVersionExact per Matt's guidance, so the catch was dead code with an infinite-loop hazard if it ever fired. - After parsing Sec-WebSocket-Protocol, fail when the client requested a subprotocol but the server returned none, preserving the prior strict WCF behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...tWebSocketTransportDuplexSessionChannel.cs | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs index 7de85e7ea3d..82585ac357e 100644 --- a/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs +++ b/src/System.ServiceModel.Http/src/System/ServiceModel/Channels/ClientWebSocketTransportDuplexSessionChannel.cs @@ -80,41 +80,38 @@ protected internal override async Task OnOpenAsync(TimeSpan timeout) { try { - while (true) - { - try - { - HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, Via) { Version = HttpVersion.Version11 }; - - // These headers were added for WCF specific handshake to avoid encoder or transfermode mismatch between client and server. - // For BinaryMessageEncoder, since we are using a sessionful channel for websocket, the encoder is actually different when - // we are using Buffered or Stramed transfermode. So we need an extra header to identify the transfermode we are using, just - // to make people a little bit easier to diagnose these mismatch issues. - if (_channelFactory.MessageVersion != MessageVersion.None) - { - request.Headers.TryAddWithoutValidation(WebSocketTransportSettings.SoapContentTypeHeader, _channelFactory.WebSocketSoapContentType); - - if (_channelFactory.MessageEncoderFactory is BinaryMessageEncoderFactory) - { - request.Headers.TryAddWithoutValidation(WebSocketTransportSettings.BinaryEncoderTransferModeHeader, _channelFactory.TransferMode.ToString()); - } - } + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, Via) { Version = HttpVersion.Version11 }; - string secValue = AddWebSocketHeaders(request); + // Override the Host header when the endpoint identity requires it (Kerberos SPN/DNS/UPN scenarios). + if (HttpChannelFactory.MapIdentity(RemoteAddress, _channelFactory.AuthenticationScheme)) + { + request.Headers.Host = HttpTransportSecurityHelpers.GetIdentityHostHeader(RemoteAddress); + } - Task sendTask = invoker is HttpClient client - ? client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) - : invoker.SendAsync(request, CancellationToken.None); - response = await sendTask.ConfigureAwait(false); + // These headers were added for WCF specific handshake to avoid encoder or transfermode mismatch between client and server. + // For BinaryMessageEncoder, since we are using a sessionful channel for websocket, the encoder is actually different when + // we are using Buffered or Stramed transfermode. So we need an extra header to identify the transfermode we are using, just + // to make people a little bit easier to diagnose these mismatch issues. + if (_channelFactory.MessageVersion != MessageVersion.None) + { + request.Headers.TryAddWithoutValidation(WebSocketTransportSettings.SoapContentTypeHeader, _channelFactory.WebSocketSoapContentType); - ValidateResponse(response, secValue); - break; - } - catch (HttpRequestException ex) when (ex.HttpRequestError == HttpRequestError.ExtendedConnectNotSupported || ex.Data.Contains("HTTP2_ENABLED")) + if (_channelFactory.MessageEncoderFactory is BinaryMessageEncoderFactory) { + request.Headers.TryAddWithoutValidation(WebSocketTransportSettings.BinaryEncoderTransferModeHeader, _channelFactory.TransferMode.ToString()); } } + string secValue = AddWebSocketHeaders(request); + + CancellationToken cancellationToken = await helper.GetCancellationTokenAsync(); + Task sendTask = invoker is HttpClient client + ? client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + : invoker.SendAsync(request, cancellationToken); + response = await sendTask.ConfigureAwait(false); + + ValidateResponse(response, secValue); + // The SecWebSocketProtocol header is optional. We should only get it with a non-empty value if we requested subprotocols, // and then it must only be one of the ones we requested. If we got a subprotocol other than one we requested (or if we // already got one in a previous header), fail. Otherwise, track which one we got. @@ -142,6 +139,12 @@ protected internal override async Task OnOpenAsync(TimeSpan timeout) } } + // If the client requested a subprotocol but the server returned none, fail to preserve prior WCF behavior. + if (subprotocol == null && !string.IsNullOrEmpty(WebSocketSettings.SubProtocol)) + { + throw FxTrace.Exception.AsError(new InvalidOperationException(SR.Format(SR.WebSocketInvalidProtocolNotInClientList, string.Empty, WebSocketSettings.SubProtocol))); + } + // Get the response stream and wrap it in a web socket. Stream connectedStream = response.Content.ReadAsStream(); Debug.Assert(connectedStream.CanWrite); @@ -286,6 +289,15 @@ private static void ValidateHeader(HttpHeaders headers, string name, string expe bool disposeInvoker = true; var handler = new SocketsHttpHandler(); handler.PooledConnectionLifetime = TimeSpan.Zero; + + // Initialize token providers used for client (HTTP auth) and proxy credentials below. + (_webRequestTokenProvider, _webRequestProxyTokenProvider) = + await _channelFactory.CreateAndOpenTokenProvidersAsync( + RemoteAddress, + Via, + channelParameterCollection, + helper.RemainingTime()); + if (_channelFactory.AllowCookies) { var cookieContainerManager = _channelFactory.GetHttpCookieContainerManager(); @@ -333,10 +345,8 @@ private static void ValidateHeader(HttpHeaders headers, string name, string expe _webRequestProxyTokenProvider, helper.RemainingTime()); } - else - { - handler.UseProxy = false; - } + // else: leave handler.UseProxy at its default so the system/default proxy + // continues to be honored (matches prior ClientWebSocket behavior). //configure handler.Credentials if (credential == CredentialCache.DefaultCredentials || credential == null) From e2bbde6982989169e239f149a220db1652e9d645 Mon Sep 17 00:00:00 2001 From: Carol Wang Date: Mon, 15 Jun 2026 14:02:46 +0800 Subject: [PATCH 4/4] Add regression test for issue #5729 Adds an outerloop scenario test that configures NetHttpsBinding with WebSocketTransportUsage.Always and a custom ServiceCertificate.SslCertificateAuthentication validator, but does NOT require a client certificate. This is the exact scenario from issue #5729: prior to the fix, the custom RemoteCertificateValidationCallback was only wired when RequireClientCertificate was true, so the validator was silently bypassed. The test asserts that the custom validator's Validate method was actually invoked during the WebSocket handshake, which would have failed before the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Https/HttpsTests.4.1.0.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/System.Private.ServiceModel/tests/Scenarios/Security/TransportSecurity/Https/HttpsTests.4.1.0.cs b/src/System.Private.ServiceModel/tests/Scenarios/Security/TransportSecurity/Https/HttpsTests.4.1.0.cs index 28667bb5919..798180b2e59 100644 --- a/src/System.Private.ServiceModel/tests/Scenarios/Security/TransportSecurity/Https/HttpsTests.4.1.0.cs +++ b/src/System.Private.ServiceModel/tests/Scenarios/Security/TransportSecurity/Https/HttpsTests.4.1.0.cs @@ -573,4 +573,67 @@ public static void WebSocket_ServerCertificateValidation() } } + // Regression test for https://github.com/dotnet/wcf/issues/5729 + // Verifies that a custom RemoteCertificateValidationCallback (configured via + // ClientCredentials.ServiceCertificate.SslCertificateAuthentication) is invoked + // during the WebSocket handshake even when no client certificate is required. + // Prior to the fix, the callback was only wired when RequireClientCertificate + // was true, so this scenario silently fell back to default validation. + [WcfFact] + [Condition(nameof(Root_Certificate_Installed), + nameof(SSL_Available), + nameof(Skip_CoreWCFService_FailedTest))] + [Issue(3572, OS = OSID.OSX)] + [Issue(1438, OS = OSID.Windows_7)] // not supported on Win7 + [OuterLoop] + public static void WebSocket_ServerCertificateValidation_NoClientCertificate() + { + EndpointAddress endpointAddress; + NetHttpsBinding binding = null; + ChannelFactory factory = null; + IWSRequestReplyService serviceProxy = null; + + try + { + // *** SETUP *** \\ + binding = new NetHttpsBinding(BasicHttpsSecurityMode.Transport) + { + MaxReceivedMessageSize = ScenarioTestHelpers.SixtyFourMB, + MaxBufferSize = ScenarioTestHelpers.SixtyFourMB, + }; + binding.WebSocketSettings.TransportUsage = WebSocketTransportUsage.Always; + binding.TransferMode = TransferMode.Buffered; + // Intentionally do NOT set Security.Transport.ClientCredentialType = Certificate; + // this is the scenario from issue #5729 where the custom validator was being ignored. + endpointAddress = new EndpointAddress(Endpoints.WebSocketHttpsRequestReplyBuffered_Address + Enum.GetName(typeof(NetHttpMessageEncoding), NetHttpMessageEncoding.Binary)); + + factory = new ChannelFactory(binding, endpointAddress); + factory.Credentials.ServiceCertificate.SslCertificateAuthentication = new X509ServiceCertificateAuthentication(); + factory.Credentials.ServiceCertificate.SslCertificateAuthentication.CertificateValidationMode = X509CertificateValidationMode.Custom; + MyX509CertificateValidator myX509CertificateValidator = new MyX509CertificateValidator(ScenarioTestHelpers.CertificateIssuerName); + factory.Credentials.ServiceCertificate.SslCertificateAuthentication.CustomCertificateValidator = myX509CertificateValidator; + + serviceProxy = factory.CreateChannel(); + + // *** EXECUTE *** \\ + // Triggers the WebSocket handshake, which is where the SSL certificate validation callback must run. + string result = serviceProxy.DownloadData(); + + // *** VALIDATE *** \\ + Assert.True(myX509CertificateValidator.validateMethodWasCalled, + "The custom X509CertificateValidator was NOT called during the WebSocket handshake. " + + "This indicates the SslCertificateAuthentication callback was not wired when RequireClientCertificate is false (regression of #5729)."); + Assert.False(string.IsNullOrEmpty(result), "DownloadData returned an empty result."); + + // *** CLEANUP *** \\ + ((ICommunicationObject)serviceProxy).Close(); + factory.Close(); + } + finally + { + // *** ENSURE CLEANUP *** \\ + ScenarioTestHelpers.CloseCommunicationObjects((ICommunicationObject)serviceProxy, factory); + } + } + }