Skip to content

Commit 61d8af2

Browse files
Tratchercaptainsafia
authored andcommitted
Implement Http/2 WebSockets (dotnet#41558)
1 parent 1eff995 commit 61d8af2

32 files changed

+461
-255
lines changed

src/Hosting/TestHost/src/RequestFeature.cs

Lines changed: 0 additions & 41 deletions
This file was deleted.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
6+
namespace Microsoft.AspNetCore.Http.Features;
7+
8+
/// <summary>
9+
/// Used with protocols that require the Extended CONNECT handshake such as HTTP/2 WebSockets and WebTransport.
10+
/// https://www.rfc-editor.org/rfc/rfc8441#section-4
11+
/// </summary>
12+
public interface IHttpExtendedConnectFeature
13+
{
14+
/// <summary>
15+
/// Indicates if the current request is a Extended CONNECT request that can be transitioned to an opaque connection via AcceptAsync.
16+
/// </summary>
17+
[MemberNotNullWhen(true, nameof(Protocol))]
18+
bool IsExtendedConnect { get; }
19+
20+
/// <summary>
21+
/// The <c>:protocol</c> header included in the request.
22+
/// </summary>
23+
string? Protocol { get; }
24+
25+
/// <summary>
26+
/// Send the response headers with a 200 status code and transition to opaque streaming.
27+
/// </summary>
28+
/// <returns>An opaque bidirectional stream.</returns>
29+
ValueTask<Stream> AcceptAsync();
30+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature
3+
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.AcceptAsync() -> System.Threading.Tasks.ValueTask<System.IO.Stream!>
4+
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.IsExtendedConnect.get -> bool
5+
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.Protocol.get -> string?

src/Middleware/WebSockets/samples/EchoApp/Properties/launchSettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"EchoApp": {
1212
"commandName": "Project",
1313
"launchBrowser": true,
14-
"launchUrl": "http://localhost:5000",
14+
"launchUrl": "https://localhost:5001",
1515
"environmentVariables": {
1616
"ASPNETCORE_ENVIRONMENT": "Development"
1717
}

src/Middleware/WebSockets/samples/EchoApp/Startup.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerF
2929
{
3030
if (context.WebSockets.IsWebSocketRequest)
3131
{
32-
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
32+
var webSocket = await context.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext() { DangerousEnableCompression = true });
3333
await Echo(context, webSocket, loggerFactory.CreateLogger("Echo"));
3434
}
3535
else

src/Middleware/WebSockets/src/HandshakeHelpers.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ internal static class HandshakeHelpers
1717
// This uses C# compiler's ability to refer to static data directly. For more information see https://vcsjones.dev/2019/02/01/csharp-readonly-span-bytes-static
1818
private static ReadOnlySpan<byte> EncodedWebSocketKey => "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"u8;
1919

20-
// Verify Method, Upgrade, Connection, version, key, etc..
21-
public static void GenerateResponseHeaders(string key, string? subProtocol, IHeaderDictionary headers)
20+
public static void GenerateResponseHeaders(bool isHttp1, IHeaderDictionary requestHeaders, string? subProtocol, IHeaderDictionary responseHeaders)
2221
{
23-
headers.Connection = HeaderNames.Upgrade;
24-
headers.Upgrade = Constants.Headers.UpgradeWebSocket;
25-
headers.SecWebSocketAccept = CreateResponseKey(key);
22+
if (isHttp1)
23+
{
24+
responseHeaders.Connection = HeaderNames.Upgrade;
25+
responseHeaders.Upgrade = Constants.Headers.UpgradeWebSocket;
26+
responseHeaders.SecWebSocketAccept = CreateResponseKey(requestHeaders.SecWebSocketKey.ToString());
27+
}
2628
if (!string.IsNullOrWhiteSpace(subProtocol))
2729
{
28-
headers.SecWebSocketProtocol = subProtocol;
30+
responseHeaders.SecWebSocketProtocol = subProtocol;
2931
}
3032
}
3133

src/Middleware/WebSockets/src/WebSocketMiddleware.cs

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,11 @@ public Task Invoke(HttpContext context)
6262
{
6363
// Detect if an opaque upgrade is available. If so, add a websocket upgrade.
6464
var upgradeFeature = context.Features.Get<IHttpUpgradeFeature>();
65-
if (upgradeFeature != null && context.Features.Get<IHttpWebSocketFeature>() == null)
65+
var connectFeature = context.Features.Get<IHttpExtendedConnectFeature>();
66+
if ((upgradeFeature != null || connectFeature != null) && context.Features.Get<IHttpWebSocketFeature>() == null)
6667
{
67-
var webSocketFeature = new UpgradeHandshake(context, upgradeFeature, _options, _logger);
68+
var webSocketFeature = new WebSocketHandshake(context, upgradeFeature, connectFeature, _options, _logger);
6869
context.Features.Set<IHttpWebSocketFeature>(webSocketFeature);
69-
7070
if (!_anyOriginAllowed)
7171
{
7272
// Check for Origin header
@@ -88,18 +88,21 @@ public Task Invoke(HttpContext context)
8888
return _next(context);
8989
}
9090

91-
private sealed class UpgradeHandshake : IHttpWebSocketFeature
91+
private sealed class WebSocketHandshake : IHttpWebSocketFeature
9292
{
9393
private readonly HttpContext _context;
94-
private readonly IHttpUpgradeFeature _upgradeFeature;
94+
private readonly IHttpUpgradeFeature? _upgradeFeature;
95+
private readonly IHttpExtendedConnectFeature? _connectFeature;
9596
private readonly WebSocketOptions _options;
9697
private readonly ILogger _logger;
9798
private bool? _isWebSocketRequest;
99+
private bool _isH2WebSocket;
98100

99-
public UpgradeHandshake(HttpContext context, IHttpUpgradeFeature upgradeFeature, WebSocketOptions options, ILogger logger)
101+
public WebSocketHandshake(HttpContext context, IHttpUpgradeFeature? upgradeFeature, IHttpExtendedConnectFeature? connectFeature, WebSocketOptions options, ILogger logger)
100102
{
101103
_context = context;
102104
_upgradeFeature = upgradeFeature;
105+
_connectFeature = connectFeature;
103106
_options = options;
104107
_logger = logger;
105108
}
@@ -110,14 +113,19 @@ public bool IsWebSocketRequest
110113
{
111114
if (_isWebSocketRequest == null)
112115
{
113-
if (!_upgradeFeature.IsUpgradableRequest)
116+
if (_connectFeature?.IsExtendedConnect == true)
114117
{
115-
_isWebSocketRequest = false;
118+
_isH2WebSocket = CheckSupportedWebSocketRequestH2(_context.Request.Method, _connectFeature.Protocol, _context.Request.Headers);
119+
_isWebSocketRequest = _isH2WebSocket;
116120
}
117-
else
121+
else if (_upgradeFeature?.IsUpgradableRequest == true)
118122
{
119123
_isWebSocketRequest = CheckSupportedWebSocketRequest(_context.Request.Method, _context.Request.Headers);
120124
}
125+
else
126+
{
127+
_isWebSocketRequest = false;
128+
}
121129
}
122130
return _isWebSocketRequest.Value;
123131
}
@@ -127,7 +135,7 @@ public async Task<WebSocket> AcceptAsync(WebSocketAcceptContext acceptContext)
127135
{
128136
if (!IsWebSocketRequest)
129137
{
130-
throw new InvalidOperationException("Not a WebSocket request."); // TODO: LOC
138+
throw new InvalidOperationException("Not a WebSocket request.");
131139
}
132140

133141
string? subProtocol = null;
@@ -154,8 +162,7 @@ public async Task<WebSocket> AcceptAsync(WebSocketAcceptContext acceptContext)
154162
}
155163
}
156164

157-
var key = _context.Request.Headers.SecWebSocketKey.ToString();
158-
HandshakeHelpers.GenerateResponseHeaders(key, subProtocol, _context.Response.Headers);
165+
HandshakeHelpers.GenerateResponseHeaders(!_isH2WebSocket, _context.Request.Headers, subProtocol, _context.Response.Headers);
159166

160167
WebSocketDeflateOptions? deflateOptions = null;
161168
if (enableCompression)
@@ -187,7 +194,18 @@ public async Task<WebSocket> AcceptAsync(WebSocketAcceptContext acceptContext)
187194
}
188195
}
189196

190-
Stream opaqueTransport = await _upgradeFeature.UpgradeAsync(); // Sets status code to 101
197+
Stream opaqueTransport;
198+
// HTTP/2
199+
if (_isH2WebSocket)
200+
{
201+
// Send the response headers
202+
opaqueTransport = await _connectFeature!.AcceptAsync();
203+
}
204+
// HTTP/1.1
205+
else
206+
{
207+
opaqueTransport = await _upgradeFeature!.UpgradeAsync(); // Sets status code to 101
208+
}
191209

192210
return WebSocket.CreateFromStream(opaqueTransport, new WebSocketCreationOptions()
193211
{
@@ -205,17 +223,22 @@ public static bool CheckSupportedWebSocketRequest(string method, IHeaderDictiona
205223
return false;
206224
}
207225

226+
if (!CheckWebSocketVersion(requestHeaders))
227+
{
228+
return false;
229+
}
230+
208231
var foundHeader = false;
209232

210-
var values = requestHeaders.GetCommaSeparatedValues(HeaderNames.SecWebSocketVersion);
233+
var values = requestHeaders.GetCommaSeparatedValues(HeaderNames.Upgrade);
211234
foreach (var value in values)
212235
{
213-
if (string.Equals(value, Constants.Headers.SupportedVersion, StringComparison.OrdinalIgnoreCase))
236+
if (string.Equals(value, Constants.Headers.UpgradeWebSocket, StringComparison.OrdinalIgnoreCase))
214237
{
215238
// WebSockets are long lived; so if the header values are valid we switch them out for the interned versions.
216239
if (values.Length == 1)
217240
{
218-
requestHeaders.SecWebSocketVersion = Constants.Headers.SupportedVersion;
241+
requestHeaders.Upgrade = Constants.Headers.UpgradeWebSocket;
219242
}
220243
foundHeader = true;
221244
break;
@@ -245,28 +268,43 @@ public static bool CheckSupportedWebSocketRequest(string method, IHeaderDictiona
245268
{
246269
return false;
247270
}
248-
foundHeader = false;
249271

250-
values = requestHeaders.GetCommaSeparatedValues(HeaderNames.Upgrade);
272+
return HandshakeHelpers.IsRequestKeyValid(requestHeaders.SecWebSocketKey.ToString());
273+
}
274+
275+
// https://datatracker.ietf.org/doc/html/rfc8441
276+
// :method = CONNECT
277+
// :protocol = websocket
278+
// :scheme = https
279+
// :path = /chat
280+
// :authority = server.example.com
281+
// sec-websocket-protocol = chat, superchat
282+
// sec-websocket-extensions = permessage-deflate
283+
// sec-websocket-version = 13
284+
// origin = http://www.example.com
285+
public static bool CheckSupportedWebSocketRequestH2(string method, string? protocol, IHeaderDictionary requestHeaders)
286+
{
287+
return HttpMethods.IsConnect(method)
288+
&& string.Equals(protocol, Constants.Headers.UpgradeWebSocket, StringComparison.OrdinalIgnoreCase)
289+
&& CheckWebSocketVersion(requestHeaders);
290+
}
291+
292+
public static bool CheckWebSocketVersion(IHeaderDictionary requestHeaders)
293+
{
294+
var values = requestHeaders.GetCommaSeparatedValues(HeaderNames.SecWebSocketVersion);
251295
foreach (var value in values)
252296
{
253-
if (string.Equals(value, Constants.Headers.UpgradeWebSocket, StringComparison.OrdinalIgnoreCase))
297+
if (string.Equals(value, Constants.Headers.SupportedVersion, StringComparison.OrdinalIgnoreCase))
254298
{
255299
// WebSockets are long lived; so if the header values are valid we switch them out for the interned versions.
256300
if (values.Length == 1)
257301
{
258-
requestHeaders.Upgrade = Constants.Headers.UpgradeWebSocket;
302+
requestHeaders.SecWebSocketVersion = Constants.Headers.SupportedVersion;
259303
}
260-
foundHeader = true;
261-
break;
304+
return true;
262305
}
263306
}
264-
if (!foundHeader)
265-
{
266-
return false;
267-
}
268-
269-
return HandshakeHelpers.IsRequestKeyValid(requestHeaders.SecWebSocketKey.ToString());
307+
return false;
270308
}
271309
}
272310

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.Hosting;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Http.Features;
8+
using Microsoft.AspNetCore.TestHost;
9+
using Microsoft.Extensions.Hosting;
10+
using Microsoft.Net.Http.Headers;
11+
12+
namespace Microsoft.AspNetCore.WebSockets.Tests;
13+
14+
public class Http2WebSocketTests
15+
{
16+
[Fact]
17+
public async Task Http2Handshake_Success()
18+
{
19+
using var host = new HostBuilder()
20+
.ConfigureWebHost(webHost =>
21+
{
22+
webHost.UseTestServer();
23+
webHost.Configure(app =>
24+
{
25+
app.UseWebSockets();
26+
app.Run(httpContext =>
27+
{
28+
Assert.True(httpContext.WebSockets.IsWebSocketRequest);
29+
Assert.Equal(new[] { "p1", "p2" }, httpContext.WebSockets.WebSocketRequestedProtocols);
30+
return httpContext.WebSockets.AcceptWebSocketAsync("p2");
31+
});
32+
});
33+
}).Start();
34+
35+
var testServer = host.GetTestServer();
36+
37+
var result = await testServer.SendAsync(httpContext =>
38+
{
39+
httpContext.Request.Method = HttpMethods.Connect;
40+
httpContext.Features.Set<IHttpExtendedConnectFeature>(new ConnectFeature()
41+
{
42+
IsExtendedConnect = true,
43+
Protocol = "WebSocket",
44+
});
45+
httpContext.Request.Headers.SecWebSocketVersion = Constants.Headers.SupportedVersion;
46+
httpContext.Request.Headers.SecWebSocketProtocol = "p1, p2";
47+
});
48+
49+
Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode);
50+
var headers = result.Response.Headers;
51+
Assert.Equal("p2", headers.SecWebSocketProtocol);
52+
Assert.False(headers.TryGetValue(HeaderNames.Connection, out var _));
53+
Assert.False(headers.TryGetValue(HeaderNames.Upgrade, out var _));
54+
Assert.False(headers.TryGetValue(HeaderNames.SecWebSocketAccept, out var _));
55+
}
56+
57+
public sealed class ConnectFeature : IHttpExtendedConnectFeature
58+
{
59+
public bool IsExtendedConnect { get; set; }
60+
public string Protocol { get; set; }
61+
public Stream Stream { get; set; } = Stream.Null;
62+
63+
/// <inheritdoc/>
64+
public ValueTask<Stream> AcceptAsync()
65+
{
66+
if (!IsExtendedConnect)
67+
{
68+
throw new InvalidOperationException("This is not an Extended CONNECT request.");
69+
}
70+
71+
return new ValueTask<Stream>(Stream);
72+
}
73+
}
74+
}

src/Middleware/WebSockets/test/UnitTests/Microsoft.AspNetCore.WebSockets.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<ProjectReference Include="$(RepoRoot)src\Hosting\Server.IntegrationTesting\src\Microsoft.AspNetCore.Server.IntegrationTesting.csproj" />
99

1010
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
11+
<Reference Include="Microsoft.AspNetCore.TestHost" />
1112
<Reference Include="Microsoft.AspNetCore.WebSockets" />
1213
</ItemGroup>
1314

0 commit comments

Comments
 (0)