Skip to content

Add WebSocket compression support #32600

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
May 27, 2021
21 changes: 19 additions & 2 deletions src/Http/Http.Features/src/WebSocketAcceptContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ namespace Microsoft.AspNetCore.Http
/// </summary>
public class WebSocketAcceptContext
{
private int _serverMaxWindowBits = 15;

/// <summary>
/// Gets or sets the subprotocol being negotiated.
/// </summary>
Expand All @@ -23,14 +25,15 @@ public class WebSocketAcceptContext

/// <summary>
/// Enables support for the 'permessage-deflate' WebSocket extension.<para />
/// Be aware that enabling compression makes the application subject to CRIME/BREACH type attacks.
/// Be aware that enabling compression over encrypted connections makes the application subject to CRIME/BREACH type attacks.
/// It is strongly advised to turn off compression when sending data containing secrets by
/// specifying <see cref="WebSocketMessageFlags.DisableCompression"/> when sending such messages.
/// </summary>
public bool DangerousEnableCompression { get; set; }

/// <summary>
/// Disables server context takeover when using compression.
/// This setting reduces the memory overhead of compression at the cost of a potentially worse compresson ratio.
/// </summary>
/// <remarks>
/// This property does nothing when <see cref="DangerousEnableCompression"/> is false,
Expand All @@ -43,14 +46,28 @@ public class WebSocketAcceptContext

/// <summary>
/// Sets the maximum base-2 logarithm of the LZ77 sliding window size that can be used for compression.
/// This setting reduces the memory overhead of compression at the cost of a potentially worse compresson ratio.
/// </summary>
/// <remarks>
/// This property does nothing when <see cref="DangerousEnableCompression"/> is false,
/// or when the client does not use compression.
/// Valid values are 9 through 15.
/// </remarks>
/// <value>
/// 15
/// </value>
public int ServerMaxWindowBits { get; set; } = 15;
public int ServerMaxWindowBits
{
get => _serverMaxWindowBits;
set
{
if (value < 9 || value > 15)
{
throw new ArgumentOutOfRangeException(nameof(ServerMaxWindowBits),
"The argument must be a value from 9 to 15.");
}
_serverMaxWindowBits = value;
}
}
}
}
5 changes: 4 additions & 1 deletion src/Http/Http/src/Internal/DefaultWebSocketManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal sealed class DefaultWebSocketManager : WebSocketManager
private readonly static Func<IFeatureCollection, IHttpWebSocketFeature?> _nullWebSocketFeature = f => null;

private FeatureReferences<FeatureInterfaces> _features;
private readonly static WebSocketAcceptContext _defaultWebSocketAcceptContext = new WebSocketAcceptContext();

public DefaultWebSocketManager(IFeatureCollection features)
{
Expand Down Expand Up @@ -62,7 +63,9 @@ public override IList<string> WebSocketRequestedProtocols

public override Task<WebSocket> AcceptWebSocketAsync(string? subProtocol)
{
return AcceptWebSocketAsync(new WebSocketAcceptContext() { SubProtocol = subProtocol });
var acceptContext = subProtocol is null ? _defaultWebSocketAcceptContext :
new WebSocketAcceptContext() { SubProtocol = subProtocol };
return AcceptWebSocketAsync(acceptContext);
}

public override Task<WebSocket> AcceptWebSocketAsync(WebSocketAcceptContext acceptContext)
Expand Down
80 changes: 65 additions & 15 deletions src/Middleware/WebSockets/src/HandshakeHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.WebSockets;
Expand Down Expand Up @@ -90,8 +91,8 @@ public static bool ParseDeflateOptions(ReadOnlySpan<char> extension, bool server
ServerContextTakeover = serverContextTakeover,
ServerMaxWindowBits = serverMaxWindowBits
};
var builder = new StringBuilder(WebSocketDeflateConstants.MaxExtensionLength);
builder.Append(WebSocketDeflateConstants.Extension);

var responseLength = WebSocketDeflateConstants.Extension.Length;

while (true)
{
Expand All @@ -116,7 +117,8 @@ public static bool ParseDeflateOptions(ReadOnlySpan<char> extension, bool server

hasClientNoContext = true;
parsedOptions.ClientContextTakeover = false;
builder.Append("; ").Append(WebSocketDeflateConstants.ClientNoContextTakeover);
// 2 = '; '
responseLength += 2 + WebSocketDeflateConstants.ClientNoContextTakeover.Length;
}
else if (value.SequenceEqual(WebSocketDeflateConstants.ServerNoContextTakeover))
{
Expand Down Expand Up @@ -162,13 +164,9 @@ public static bool ParseDeflateOptions(ReadOnlySpan<char> extension, bool server
// parameter in the corresponding extension negotiation response to the
// offer with a value equal to or smaller than the received value.
parsedOptions.ClientMaxWindowBits = clientMaxWindowBits ?? 15;

// If a received extension negotiation offer doesn't have the
// "client_max_window_bits" extension parameter, the corresponding
// extension negotiation response to the offer MUST NOT include the
// "client_max_window_bits" extension parameter.
builder.Append("; ").Append(WebSocketDeflateConstants.ClientMaxWindowBits).Append('=')
.Append(parsedOptions.ClientMaxWindowBits.ToString(CultureInfo.InvariantCulture));
// 2 = '; ', 1 = '='
responseLength += 2 + WebSocketDeflateConstants.ClientMaxWindowBits.Length + 1 +
((parsedOptions.ClientMaxWindowBits > 9) ? 2 : 1);
}
else if (value.StartsWith(WebSocketDeflateConstants.ServerMaxWindowBits))
{
Expand Down Expand Up @@ -248,17 +246,69 @@ static bool ParseWindowBits(ReadOnlySpan<char> value, string propertyName, out i

if (!parsedOptions.ServerContextTakeover)
{
builder.Append("; ").Append(WebSocketDeflateConstants.ServerNoContextTakeover);
// 2 = '; '
responseLength += 2 + WebSocketDeflateConstants.ServerNoContextTakeover.Length;
}

if (hasServerMaxWindowBits || parsedOptions.ServerMaxWindowBits != 15)
{
builder.Append("; ")
.Append(WebSocketDeflateConstants.ServerMaxWindowBits).Append('=')
.Append(parsedOptions.ServerMaxWindowBits.ToString(CultureInfo.InvariantCulture));
// 2 = '; ', 1 = '='
responseLength += 2 + WebSocketDeflateConstants.ServerMaxWindowBits.Length + 1 +
((parsedOptions.ServerMaxWindowBits > 9) ? 2 : 1);
}

response = builder.ToString();
response = string.Create(responseLength, (parsedOptions, hasClientMaxWindowBits, hasServerMaxWindowBits, hasClientNoContext),
static (span, state) =>
{
WebSocketDeflateConstants.Extension.AsSpan().CopyTo(span);
span = span.Slice(WebSocketDeflateConstants.Extension.Length);
if (state.hasClientNoContext)
{
span[0] = ';';
span[1] = ' ';
span = span.Slice(2);
WebSocketDeflateConstants.ClientNoContextTakeover.AsSpan().CopyTo(span);
span = span.Slice(WebSocketDeflateConstants.ClientNoContextTakeover.Length);
}
if (state.hasClientMaxWindowBits)
{
// If a received extension negotiation offer doesn't have the
// "client_max_window_bits" extension parameter, the corresponding
// extension negotiation response to the offer MUST NOT include the
// "client_max_window_bits" extension parameter.
span[0] = ';';
span[1] = ' ';
span = span.Slice(2);
WebSocketDeflateConstants.ClientMaxWindowBits.AsSpan().CopyTo(span);
span = span.Slice(WebSocketDeflateConstants.ClientMaxWindowBits.Length);
span[0] = '=';
span = span.Slice(1);
var ret = state.parsedOptions.ClientMaxWindowBits.TryFormat(span, out var written);
Debug.Assert(ret);
span = span.Slice(written);
}
if (!state.parsedOptions.ServerContextTakeover)
{
span[0] = ';';
span[1] = ' ';
span = span.Slice(2);
WebSocketDeflateConstants.ServerNoContextTakeover.AsSpan().CopyTo(span);
span = span.Slice(WebSocketDeflateConstants.ServerNoContextTakeover.Length);
}
if (state.hasServerMaxWindowBits || state.parsedOptions.ServerMaxWindowBits != 15)
{
span[0] = ';';
span[1] = ' ';
span = span.Slice(2);
WebSocketDeflateConstants.ServerMaxWindowBits.AsSpan().CopyTo(span);
span = span.Slice(WebSocketDeflateConstants.ServerMaxWindowBits.Length);
span[0] = '=';
span = span.Slice(1);
var ret = state.parsedOptions.ServerMaxWindowBits.TryFormat(span, out var written);
Debug.Assert(ret);
span = span.Slice(written);
}
});

return true;
}
Expand Down
9 changes: 2 additions & 7 deletions src/Middleware/WebSockets/src/WebSocketDeflateConstants.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;

namespace Microsoft.AspNetCore.WebSockets
{
internal static class WebSocketDeflateConstants
{
/// <summary>
/// The maximum length that this extension can have, assuming that we're not using extra white space.
/// <para />
/// "permessage-deflate; client_max_window_bits=15; client_no_context_takeover; server_max_window_bits=15; server_no_context_takeover"
/// </summary>
public const int MaxExtensionLength = 128;

public const string Extension = "permessage-deflate";

public const string ClientMaxWindowBits = "client_max_window_bits";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory)
if (context.WebSockets.IsWebSocketRequest)
{
logger.LogInformation("Received WebSocket request");
using (var webSocket = await context.WebSockets.AcceptWebSocketAsync())
using (var webSocket = await context.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext()
{
DangerousEnableCompression = true
}))
{
await Echo(webSocket, context.RequestAborted);
}
Expand Down