Skip to content

Commit 24417fa

Browse files
committed
Negotiate protocl versioning
1 parent bcdc9b0 commit 24417fa

File tree

12 files changed

+174
-28
lines changed

12 files changed

+174
-28
lines changed

src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,71 @@ public async Task CheckFixedMessage(string protocolName, HttpTransportType trans
114114
}
115115
}
116116

117+
[Fact]
118+
public async Task ServerRejectsClientWithOldProtocol()
119+
{
120+
bool ExpectedError(WriteContext writeContext)
121+
{
122+
return writeContext.LoggerName == typeof(HttpConnection).FullName &&
123+
writeContext.EventId.Name == "ErrorWithNegotiation";
124+
}
125+
126+
var protocol = HubProtocols["json"];
127+
using (StartServer<Startup>(out var server, ExpectedError))
128+
{
129+
var connectionBuilder = new HubConnectionBuilder()
130+
.WithLoggerFactory(LoggerFactory)
131+
.WithUrl(server.Url + "/negotiateProtocolVersion12", HttpTransportType.LongPolling);
132+
connectionBuilder.Services.AddSingleton(protocol);
133+
134+
var connection = connectionBuilder.Build();
135+
136+
try
137+
{
138+
var ex = await Assert.ThrowsAnyAsync<Exception>(() => connection.StartAsync().OrTimeout());
139+
Assert.Equal("The client requested version '1', but the server does not support this version.", ex.Message);
140+
}
141+
catch (Exception ex)
142+
{
143+
LoggerFactory.CreateLogger<HubConnectionTests>().LogError(ex, "{ExceptionType} from test", ex.GetType().FullName);
144+
throw;
145+
}
146+
finally
147+
{
148+
await connection.DisposeAsync().OrTimeout();
149+
}
150+
}
151+
}
152+
153+
[Fact]
154+
public async Task ClientCanConnectToServerWithLowerMinimumProtocol()
155+
{
156+
var protocol = HubProtocols["json"];
157+
using (StartServer<Startup>(out var server))
158+
{
159+
var connectionBuilder = new HubConnectionBuilder()
160+
.WithLoggerFactory(LoggerFactory)
161+
.WithUrl(server.Url + "/negotiateProtocolVersionNegative", HttpTransportType.LongPolling);
162+
connectionBuilder.Services.AddSingleton(protocol);
163+
164+
var connection = connectionBuilder.Build();
165+
166+
try
167+
{
168+
await connection.StartAsync().OrTimeout();
169+
}
170+
catch (Exception ex)
171+
{
172+
LoggerFactory.CreateLogger<HubConnectionTests>().LogError(ex, "{ExceptionType} from test", ex.GetType().FullName);
173+
throw;
174+
}
175+
finally
176+
{
177+
await connection.DisposeAsync().OrTimeout();
178+
}
179+
}
180+
}
181+
117182
[Theory]
118183
[MemberData(nameof(HubProtocolsAndTransportsAndHubPaths))]
119184
public async Task CanSendAndReceiveMessage(string protocolName, HttpTransportType transportType, string path)

src/SignalR/clients/csharp/Client/test/FunctionalTests/Startup.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ public void Configure(IApplicationBuilder app)
6969

7070
endpoints.MapHub<TestHub>("/default-nowebsockets", options => options.Transports = HttpTransportType.LongPolling | HttpTransportType.ServerSentEvents);
7171

72+
endpoints.MapHub<TestHub>("/negotiateProtocolVersion12", options =>
73+
{
74+
options.MinimumProtocolVersion = 12;
75+
});
76+
77+
endpoints.MapHub<TestHub>("/negotiateProtocolVersionNegative", options =>
78+
{
79+
options.MinimumProtocolVersion = -1;
80+
});
81+
7282
endpoints.MapGet("/generateJwtToken", context =>
7383
{
7484
return context.Response.WriteAsync(GenerateJwtToken());

src/SignalR/clients/csharp/Client/test/UnitTests/HttpConnectionTests.ConnectionLifecycle.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ public async Task SSEWaitsForResponseToStart()
359359
var httpHandler = new TestHttpMessageHandler();
360360

361361
var connectResponseTcs = new TaskCompletionSource<object>();
362-
httpHandler.OnGet("/?id=00000000-0000-0000-0000-000000000000", async (_, __) =>
362+
httpHandler.OnGet("/?version=1&id=00000000-0000-0000-0000-000000000000", async (_, __) =>
363363
{
364364
await connectResponseTcs.Task;
365365
return ResponseUtils.CreateResponse(HttpStatusCode.Accepted);

src/SignalR/clients/csharp/Client/test/UnitTests/HttpConnectionTests.Negotiate.cs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ public Task ConnectionCannotBeStartedIfNoTransportProvidedByServer()
5050
}
5151

5252
[Theory]
53-
[InlineData("http://fakeuri.org/", "http://fakeuri.org/negotiate")]
54-
[InlineData("http://fakeuri.org/?q=1/0", "http://fakeuri.org/negotiate?q=1/0")]
55-
[InlineData("http://fakeuri.org?q=1/0", "http://fakeuri.org/negotiate?q=1/0")]
56-
[InlineData("http://fakeuri.org/endpoint", "http://fakeuri.org/endpoint/negotiate")]
57-
[InlineData("http://fakeuri.org/endpoint/", "http://fakeuri.org/endpoint/negotiate")]
58-
[InlineData("http://fakeuri.org/endpoint?q=1/0", "http://fakeuri.org/endpoint/negotiate?q=1/0")]
53+
[InlineData("http://fakeuri.org/", "http://fakeuri.org/negotiate?version=1")]
54+
[InlineData("http://fakeuri.org/?q=1/0", "http://fakeuri.org/negotiate?q=1/0&version=1")]
55+
[InlineData("http://fakeuri.org?q=1/0", "http://fakeuri.org/negotiate?q=1/0&version=1")]
56+
[InlineData("http://fakeuri.org/endpoint", "http://fakeuri.org/endpoint/negotiate?version=1")]
57+
[InlineData("http://fakeuri.org/endpoint/", "http://fakeuri.org/endpoint/negotiate?version=1")]
58+
[InlineData("http://fakeuri.org/endpoint?q=1/0", "http://fakeuri.org/endpoint/negotiate?q=1/0&version=1")]
5959
public async Task CorrectlyHandlesQueryStringWhenAppendingNegotiateToUrl(string requestedUrl, string expectedNegotiate)
6060
{
6161
var testHttpHandler = new TestHttpMessageHandler(autoNegotiate: false);
@@ -172,10 +172,10 @@ await WithConnectionAsync(
172172
});
173173
}
174174

175-
Assert.Equal("http://fakeuri.org/negotiate", testHttpHandler.ReceivedRequests[0].RequestUri.ToString());
176-
Assert.Equal("https://another.domain.url/chat/negotiate", testHttpHandler.ReceivedRequests[1].RequestUri.ToString());
177-
Assert.Equal("https://another.domain.url/chat?id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[2].RequestUri.ToString());
178-
Assert.Equal("https://another.domain.url/chat?id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[3].RequestUri.ToString());
175+
Assert.Equal("http://fakeuri.org/negotiate?version=1", testHttpHandler.ReceivedRequests[0].RequestUri.ToString());
176+
Assert.Equal("https://another.domain.url/chat/negotiate?version=1", testHttpHandler.ReceivedRequests[1].RequestUri.ToString());
177+
Assert.Equal("https://another.domain.url/chat?version=1&id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[2].RequestUri.ToString());
178+
Assert.Equal("https://another.domain.url/chat?version=1&id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[3].RequestUri.ToString());
179179
Assert.Equal(5, testHttpHandler.ReceivedRequests.Count);
180180
}
181181

@@ -278,10 +278,10 @@ await WithConnectionAsync(
278278
});
279279
}
280280

281-
Assert.Equal("http://fakeuri.org/negotiate", testHttpHandler.ReceivedRequests[0].RequestUri.ToString());
282-
Assert.Equal("https://another.domain.url/chat/negotiate", testHttpHandler.ReceivedRequests[1].RequestUri.ToString());
283-
Assert.Equal("https://another.domain.url/chat?id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[2].RequestUri.ToString());
284-
Assert.Equal("https://another.domain.url/chat?id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[3].RequestUri.ToString());
281+
Assert.Equal("http://fakeuri.org/negotiate?version=1", testHttpHandler.ReceivedRequests[0].RequestUri.ToString());
282+
Assert.Equal("https://another.domain.url/chat/negotiate?version=1", testHttpHandler.ReceivedRequests[1].RequestUri.ToString());
283+
Assert.Equal("https://another.domain.url/chat?version=1&id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[2].RequestUri.ToString());
284+
Assert.Equal("https://another.domain.url/chat?version=1&id=0rge0d00-0040-0030-0r00-000q00r00e00", testHttpHandler.ReceivedRequests[3].RequestUri.ToString());
285285
// Delete request
286286
Assert.Equal(5, testHttpHandler.ReceivedRequests.Count);
287287
}

src/SignalR/clients/csharp/Client/test/UnitTests/TestHttpMessageHandler.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
14
using System;
25
using System.Collections.Generic;
36
using System.Net;
@@ -117,7 +120,7 @@ public static TestHttpMessageHandler CreateDefault()
117120
});
118121
testHttpMessageHandler.OnRequest((request, next, cancellationToken) =>
119122
{
120-
if (request.Method.Equals(HttpMethod.Delete) && request.RequestUri.PathAndQuery.StartsWith("/?id="))
123+
if (request.Method.Equals(HttpMethod.Delete) && request.RequestUri.PathAndQuery.Contains("&id="))
121124
{
122125
deleteCts.Cancel();
123126
return Task.FromResult(ResponseUtils.CreateResponse(HttpStatusCode.Accepted));

src/SignalR/clients/csharp/Http.Connections.Client/src/HttpConnection.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public partial class HttpConnection : ConnectionContext, IConnectionInherentKeep
2626
// Not configurable on purpose, high enough that if we reach here, it's likely
2727
// a buggy server
2828
private static readonly int _maxRedirects = 100;
29+
private static readonly int _protocolVersionNumber = 1;
2930
private static readonly Task<string> _noAccessToken = Task.FromResult<string>(null);
3031

3132
private static readonly TimeSpan HttpClientTimeout = TimeSpan.FromSeconds(120);
@@ -428,8 +429,10 @@ private async Task<NegotiationResponse> NegotiateAsync(Uri url, HttpClient httpC
428429
urlBuilder.Path += "/";
429430
}
430431
urlBuilder.Path += "negotiate";
432+
//urlBuilder.Query = $"version={_protocolVersionNumber}";
433+
var uri = Utils.AppendQueryString(urlBuilder.Uri, $"version={_protocolVersionNumber}");
431434

432-
using (var request = new HttpRequestMessage(HttpMethod.Post, urlBuilder.Uri))
435+
using (var request = new HttpRequestMessage(HttpMethod.Post, uri))
433436
{
434437
// Corefx changed the default version and High Sierra curlhandler tries to upgrade request
435438
request.Version = new Version(1, 1);
@@ -466,7 +469,7 @@ private static Uri CreateConnectUrl(Uri url, string connectionId)
466469
throw new FormatException("Invalid connection id.");
467470
}
468471

469-
return Utils.AppendQueryString(url, "id=" + connectionId);
472+
return Utils.AppendQueryString(url, $"version={_protocolVersionNumber}&id=" + connectionId);
470473
}
471474

472475
private async Task StartTransport(Uri connectUrl, HttpTransportType transportType, TransferFormat transferFormat, CancellationToken cancellationToken)

src/SignalR/common/Http.Connections.Common/src/NegotiateProtocol.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public static class NegotiateProtocol
2727
private static JsonEncodedText TransferFormatsPropertyNameBytes = JsonEncodedText.Encode(TransferFormatsPropertyName);
2828
private const string ErrorPropertyName = "error";
2929
private static JsonEncodedText ErrorPropertyNameBytes = JsonEncodedText.Encode(ErrorPropertyName);
30+
private const string VersionPropertyName = "version";
31+
private static JsonEncodedText VersionPropertyNameBytes = JsonEncodedText.Encode(VersionPropertyName);
3032

3133
// Use C#7.3's ReadOnlySpan<byte> optimization for static data https://vcsjones.com/2019/02/01/csharp-readonly-span-bytes-static/
3234
// Used to detect ASP.NET SignalR Server connection attempt
@@ -41,6 +43,22 @@ public static void WriteResponse(NegotiationResponse response, IBufferWriter<byt
4143
var writer = reusableWriter.GetJsonWriter();
4244
writer.WriteStartObject();
4345

46+
// If we already have an error its due to a protocol version incompatibility.
47+
// We can just write the error and complete the JSON object and return.
48+
if (!string.IsNullOrEmpty(response.Error))
49+
{
50+
writer.WriteString(ErrorPropertyNameBytes, response.Error);
51+
writer.WriteEndObject();
52+
writer.Flush();
53+
Debug.Assert(writer.CurrentDepth == 0);
54+
return;
55+
}
56+
57+
if (response.Version > 0)
58+
{
59+
writer.WriteNumber(VersionPropertyNameBytes, response.Version);
60+
}
61+
4462
if (!string.IsNullOrEmpty(response.Url))
4563
{
4664
writer.WriteString(UrlPropertyNameBytes, response.Url);
@@ -116,6 +134,7 @@ public static NegotiationResponse ParseResponse(ReadOnlySpan<byte> content)
116134
string accessToken = null;
117135
List<AvailableTransport> availableTransports = null;
118136
string error = null;
137+
int version = 0;
119138

120139
var completed = false;
121140
while (!completed && reader.CheckRead())
@@ -135,6 +154,10 @@ public static NegotiationResponse ParseResponse(ReadOnlySpan<byte> content)
135154
{
136155
connectionId = reader.ReadAsString(ConnectionIdPropertyName);
137156
}
157+
else if (reader.ValueTextEquals(VersionPropertyNameBytes.EncodedUtf8Bytes))
158+
{
159+
version = reader.ReadAsInt32(VersionPropertyName).GetValueOrDefault();
160+
}
138161
else if (reader.ValueTextEquals(AvailableTransportsPropertyNameBytes.EncodedUtf8Bytes))
139162
{
140163
reader.CheckRead();
@@ -195,6 +218,7 @@ public static NegotiationResponse ParseResponse(ReadOnlySpan<byte> content)
195218
AccessToken = accessToken,
196219
AvailableTransports = availableTransports,
197220
Error = error,
221+
Version = version
198222
};
199223
}
200224
catch (Exception ex)

src/SignalR/common/Http.Connections.Common/src/NegotiationResponse.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public class NegotiationResponse
1010
public string Url { get; set; }
1111
public string AccessToken { get; set; }
1212
public string ConnectionId { get; set; }
13+
public int Version { get; set; }
1314
public IList<AvailableTransport> AvailableTransports { get; set; }
1415
public string Error { get; set; }
1516
}

src/SignalR/common/Http.Connections/ref/Microsoft.AspNetCore.Http.Connections.netcoreapp.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public HttpConnectionDispatcherOptions() { }
5353
public long ApplicationMaxBufferSize { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
5454
public System.Collections.Generic.IList<Microsoft.AspNetCore.Authorization.IAuthorizeData> AuthorizationData { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
5555
public Microsoft.AspNetCore.Http.Connections.LongPollingOptions LongPolling { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
56+
public int MinimumProtocolVersion { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
5657
public long TransportMaxBufferSize { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
5758
public Microsoft.AspNetCore.Http.Connections.HttpTransportType Transports { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
5859
public Microsoft.AspNetCore.Http.Connections.WebSocketOptions WebSockets { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }

src/SignalR/common/Http.Connections/src/HttpConnectionDispatcherOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,11 @@ public HttpConnectionDispatcherOptions()
5757
/// Gets or sets the maximum buffer size of the application writer.
5858
/// </summary>
5959
public long ApplicationMaxBufferSize { get; set; }
60+
61+
/// <summary>
62+
/// Gets or sets the minimum protocol verison supported by the server.
63+
/// The default value is 0, the lowest possible protocol version.
64+
/// </summary>
65+
public int MinimumProtocolVersion { get; set; } = 0;
6066
}
6167
}

src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ internal partial class HttpConnectionDispatcher
4545
private readonly HttpConnectionManager _manager;
4646
private readonly ILoggerFactory _loggerFactory;
4747
private readonly ILogger _logger;
48+
private static readonly int _protocolVersion = 1;
4849

4950
public HttpConnectionDispatcher(HttpConnectionManager manager, ILoggerFactory loggerFactory)
5051
{
@@ -308,9 +309,36 @@ private async Task ProcessNegotiate(HttpContext context, HttpConnectionDispatche
308309

309310
private static void WriteNegotiatePayload(IBufferWriter<byte> writer, string connectionId, HttpContext context, HttpConnectionDispatcherOptions options)
310311
{
311-
var response = new NegotiationResponse();
312-
response.ConnectionId = connectionId;
313-
response.AvailableTransports = new List<AvailableTransport>();
312+
var response = new NegotiationResponse
313+
{
314+
ConnectionId = connectionId,
315+
AvailableTransports = new List<AvailableTransport>(),
316+
};
317+
318+
if (context.Request.Query.TryGetValue("version", out var queryStringVersion))
319+
{
320+
// Set the negotiate response to the protocol we use.
321+
var clientProtocolVersion = int.Parse(queryStringVersion.ToString());
322+
if (clientProtocolVersion < options.MinimumProtocolVersion)
323+
{
324+
response.Error = $"The client requested version '{clientProtocolVersion}', but the server does not support this version.";
325+
NegotiateProtocol.WriteResponse(response, writer);
326+
return;
327+
}
328+
else if (clientProtocolVersion > _protocolVersion)
329+
{
330+
response.Version = _protocolVersion;
331+
}
332+
else
333+
{
334+
response.Version = clientProtocolVersion;
335+
}
336+
} else if (options.MinimumProtocolVersion > 0)
337+
{
338+
response.Error = $"The client requested version '0', but the server does not support this version.";
339+
NegotiateProtocol.WriteResponse(response, writer);
340+
return;
341+
}
314342

315343
if ((options.Transports & HttpTransportType.WebSockets) != 0 && ServerHasWebSockets(context.Features))
316344
{

0 commit comments

Comments
 (0)