Skip to content
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

Add WebSocket compression support #32600

Merged
merged 14 commits into from
May 27, 2021
3 changes: 3 additions & 0 deletions src/Http/Headers/src/HeaderNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ public static class HeaderNames
/// <summary>Gets the <c>Sec-WebSocket-Version</c> HTTP header name.</summary>
public static readonly string SecWebSocketVersion = "Sec-WebSocket-Version";

/// <summary>Gets the <c>Sec-WebSocket-Extensions</c> HTTP header name.</summary>
public static readonly string SecWebSocketExtensions = "Sec-WebSocket-Extensions";

/// <summary>Gets the <c>Server</c> HTTP header name.</summary>
public static readonly string Server = "Server";

Expand Down
1 change: 1 addition & 0 deletions src/Http/Headers/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Microsoft.Net.Http.Headers.RangeConditionHeaderValue.RangeConditionHeaderValue(M
static readonly Microsoft.Net.Http.Headers.HeaderNames.Baggage -> string!
static readonly Microsoft.Net.Http.Headers.HeaderNames.Link -> string!
static readonly Microsoft.Net.Http.Headers.HeaderNames.ProxyConnection -> string!
static readonly Microsoft.Net.Http.Headers.HeaderNames.SecWebSocketExtensions -> string!
static readonly Microsoft.Net.Http.Headers.HeaderNames.XContentTypeOptions -> string!
static readonly Microsoft.Net.Http.Headers.HeaderNames.XPoweredBy -> string!
static readonly Microsoft.Net.Http.Headers.HeaderNames.XUACompatible -> string!
Expand Down
1 change: 1 addition & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ abstract Microsoft.AspNetCore.Http.HttpRequest.ContentType.get -> string?
static Microsoft.AspNetCore.Builder.UseExtensions.Use(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, System.Func<Microsoft.AspNetCore.Http.HttpContext!, Microsoft.AspNetCore.Http.RequestDelegate!, System.Threading.Tasks.Task!>! middleware) -> Microsoft.AspNetCore.Builder.IApplicationBuilder!
static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, System.Type! middleware, params object?[]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder!
static Microsoft.AspNetCore.Builder.UseMiddlewareExtensions.UseMiddleware<TMiddleware>(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, params object?[]! args) -> Microsoft.AspNetCore.Builder.IApplicationBuilder!
virtual Microsoft.AspNetCore.Http.WebSocketManager.AcceptWebSocketAsync(Microsoft.AspNetCore.Http.WebSocketAcceptContext! acceptContext) -> System.Threading.Tasks.Task<System.Net.WebSockets.WebSocket!>!
10 changes: 9 additions & 1 deletion src/Http/Http.Abstractions/src/WebSocketManager.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// 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;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Http
{
/// <summary>
/// Manages the establishment of WebSocket connections for a specific HTTP request.
/// Manages the establishment of WebSocket connections for a specific HTTP request.
/// </summary>
public abstract class WebSocketManager
{
Expand Down Expand Up @@ -37,5 +38,12 @@ public virtual Task<WebSocket> AcceptWebSocketAsync()
/// <param name="subProtocol">The sub-protocol to use.</param>
/// <returns>A task representing the completion of the transition.</returns>
public abstract Task<WebSocket> AcceptWebSocketAsync(string? subProtocol);

/// <summary>
///
/// </summary>
/// <param name="acceptContext"></param>
/// <returns></returns>
public virtual Task<WebSocket> AcceptWebSocketAsync(WebSocketAcceptContext acceptContext) => throw new NotImplementedException();
}
}
3 changes: 3 additions & 0 deletions src/Http/Http.Features/src/IHeaderDictionary.Keyed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ public partial interface IHeaderDictionary
/// <summary>Gets or sets the <c>Sec-WebSocket-Version</c> HTTP header.</summary>
StringValues SecWebSocketVersion { get => this[HeaderNames.SecWebSocketVersion]; set => this[HeaderNames.SecWebSocketVersion] = value; }

/// <summary>Gets or sets the <c>Sec-WebSocket-Extensions</c> HTTP header.</summary>
StringValues SecWebSocketExtensions { get => this[HeaderNames.SecWebSocketExtensions]; set => this[HeaderNames.SecWebSocketExtensions] = value; }

/// <summary>Gets or sets the <c>Server</c> HTTP header.</summary>
StringValues Server { get => this[HeaderNames.Server]; set => this[HeaderNames.Server] = value; }

Expand Down
10 changes: 10 additions & 0 deletions src/Http/Http.Features/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ Microsoft.AspNetCore.Http.IHeaderDictionary.RetryAfter.get -> Microsoft.Extensio
Microsoft.AspNetCore.Http.IHeaderDictionary.RetryAfter.set -> void
Microsoft.AspNetCore.Http.IHeaderDictionary.SecWebSocketAccept.get -> Microsoft.Extensions.Primitives.StringValues
Microsoft.AspNetCore.Http.IHeaderDictionary.SecWebSocketAccept.set -> void
Microsoft.AspNetCore.Http.IHeaderDictionary.SecWebSocketExtensions.get -> Microsoft.Extensions.Primitives.StringValues
Microsoft.AspNetCore.Http.IHeaderDictionary.SecWebSocketExtensions.set -> void
Microsoft.AspNetCore.Http.IHeaderDictionary.SecWebSocketKey.get -> Microsoft.Extensions.Primitives.StringValues
Microsoft.AspNetCore.Http.IHeaderDictionary.SecWebSocketKey.set -> void
Microsoft.AspNetCore.Http.IHeaderDictionary.SecWebSocketProtocol.get -> Microsoft.Extensions.Primitives.StringValues
Expand Down Expand Up @@ -232,6 +234,14 @@ Microsoft.AspNetCore.Http.Features.FeatureCollection.IsReadOnly.get -> bool (for
Microsoft.AspNetCore.Http.Features.FeatureCollection.Set<TFeature>(TFeature? instance) -> void (forwarded, contained in Microsoft.Extensions.Features)
Microsoft.AspNetCore.Http.Features.FeatureCollection.this[System.Type! key].get -> object? (forwarded, contained in Microsoft.Extensions.Features)
Microsoft.AspNetCore.Http.Features.FeatureCollection.this[System.Type! key].set -> void (forwarded, contained in Microsoft.Extensions.Features)
Microsoft.AspNetCore.Http.WebSocketAcceptContext.DangerousEnableCompression.get -> bool
Microsoft.AspNetCore.Http.WebSocketAcceptContext.DangerousEnableCompression.set -> void
Microsoft.AspNetCore.Http.WebSocketAcceptContext.DisableServerContextTakeover.get -> bool
Microsoft.AspNetCore.Http.WebSocketAcceptContext.DisableServerContextTakeover.set -> void
Microsoft.AspNetCore.Http.WebSocketAcceptContext.ServerMaxWindowBits.get -> int
Microsoft.AspNetCore.Http.WebSocketAcceptContext.ServerMaxWindowBits.set -> void
virtual Microsoft.AspNetCore.Http.Features.FeatureCollection.Revision.get -> int (forwarded, contained in Microsoft.Extensions.Features)
virtual Microsoft.AspNetCore.Http.WebSocketAcceptContext.KeepAliveInterval.get -> System.TimeSpan?
virtual Microsoft.AspNetCore.Http.WebSocketAcceptContext.KeepAliveInterval.set -> void
~Microsoft.AspNetCore.Http.Features.FeatureReference<> (forwarded, contained in Microsoft.Extensions.Features)
~Microsoft.AspNetCore.Http.Features.FeatureReferences<> (forwarded, contained in Microsoft.Extensions.Features)
40 changes: 39 additions & 1 deletion src/Http/Http.Features/src/WebSocketAcceptContext.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// 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 Microsoft.AspNetCore.Http.Features;
using System;
using System.Net.WebSockets;

namespace Microsoft.AspNetCore.Http
{
Expand All @@ -14,5 +15,42 @@ public class WebSocketAcceptContext
/// Gets or sets the subprotocol being negotiated.
/// </summary>
public virtual string? SubProtocol { get; set; }

/// <summary>
/// The interval to send pong frames. This is a heart-beat that keeps the connection alive.
/// </summary>
public virtual TimeSpan? KeepAliveInterval { get; set; }

/// <summary>
/// Enables support for the 'permessage-deflate' WebSocket extension.<para />
/// Be aware that enabling compression makes the application subject to CRIME/BREACH type attacks.
BrennanConroy marked this conversation as resolved.
Show resolved Hide resolved
/// 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.
BrennanConroy marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
/// <remarks>
/// This property does nothing when <see cref="DangerousEnableCompression"/> is false,
/// or when the client does not use compression.
/// </remarks>
/// <value>
/// false
/// </value>
public bool DisableServerContextTakeover { get; set; }

/// <summary>
/// Sets the maximum base-2 logarithm of the LZ77 sliding window size that can be used for compression.
BrennanConroy marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
/// <remarks>
/// This property does nothing when <see cref="DangerousEnableCompression"/> is false,
/// or when the client does not use compression.
/// </remarks>
/// <value>
/// 15
/// </value>
public int ServerMaxWindowBits { get; set; } = 15;
}
}
7 changes: 6 additions & 1 deletion src/Http/Http/src/Internal/DefaultWebSocketManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,17 @@ public override IList<string> WebSocketRequestedProtocols
}

public override Task<WebSocket> AcceptWebSocketAsync(string? subProtocol)
{
return AcceptWebSocketAsync(new WebSocketAcceptContext() { SubProtocol = subProtocol });
BrennanConroy marked this conversation as resolved.
Show resolved Hide resolved
}

public override Task<WebSocket> AcceptWebSocketAsync(WebSocketAcceptContext acceptContext)
{
if (WebSocketFeature == null)
{
throw new NotSupportedException("WebSockets are not supported");
}
return WebSocketFeature.AcceptAsync(new WebSocketAcceptContext() { SubProtocol = subProtocol });
return WebSocketFeature.AcceptAsync(acceptContext);
}

struct FeatureInterfaces
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace Microsoft.AspNetCore.WebSockets
/// <summary>
/// Extends the <see cref="WebSocketAcceptContext"/> class with additional properties.
/// </summary>
[Obsolete("This type is obsolete and will be removed in a future version. The recommended alternative is Microsoft.AspNetCore.Http.WebSocketAcceptContext.")]
public class ExtendedWebSocketAcceptContext : WebSocketAcceptContext
{
/// <inheritdoc />
Expand All @@ -23,6 +24,6 @@ public class ExtendedWebSocketAcceptContext : WebSocketAcceptContext
/// <summary>
/// The interval to send pong frames. This is a heart-beat that keeps the connection alive.
/// </summary>
public TimeSpan? KeepAliveInterval { get; set; }
public new TimeSpan? KeepAliveInterval { get; set; }
}
}
190 changes: 190 additions & 0 deletions src/Middleware/WebSockets/src/HandshakeHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -72,5 +75,192 @@ public static string CreateResponseKey(string requestKey)

return Convert.ToBase64String(hashedBytes);
}

// https://datatracker.ietf.org/doc/html/rfc7692#section-7.1
public static bool ParseDeflateOptions(ReadOnlySpan<char> extension, bool serverContextTakeover,
int serverMaxWindowBits, out WebSocketDeflateOptions parsedOptions, [NotNullWhen(true)] out string? response)
{
bool hasServerMaxWindowBits = false;
bool hasClientMaxWindowBits = false;
bool hasClientNoContext = false;
bool hasServerNoContext = false;
response = null;
parsedOptions = new WebSocketDeflateOptions()
{
ServerContextTakeover = serverContextTakeover,
ServerMaxWindowBits = serverMaxWindowBits
};
var builder = new StringBuilder(WebSocketDeflateConstants.MaxExtensionLength);
BrennanConroy marked this conversation as resolved.
Show resolved Hide resolved
builder.Append(WebSocketDeflateConstants.Extension);

while (true)
{
int end = extension.IndexOf(';');
ReadOnlySpan<char> value = (end >= 0 ? extension[..end] : extension).Trim();

if (value.Length == 0)
{
break;
}

if (value.SequenceEqual(WebSocketDeflateConstants.ClientNoContextTakeover))
{
// https://datatracker.ietf.org/doc/html/rfc7692#section-7
// MUST decline if:
// The negotiation offer contains multiple extension parameters with
// the same name.
if (hasClientNoContext)
{
return false;
}

hasClientNoContext = true;
parsedOptions.ClientContextTakeover = false;
builder.Append("; ").Append(WebSocketDeflateConstants.ClientNoContextTakeover);
}
else if (value.SequenceEqual(WebSocketDeflateConstants.ServerNoContextTakeover))
{
// https://datatracker.ietf.org/doc/html/rfc7692#section-7
// MUST decline if:
// The negotiation offer contains multiple extension parameters with
// the same name.
if (hasServerNoContext)
{
return false;
}

hasServerNoContext = true;
parsedOptions.ServerContextTakeover = false;
}
else if (value.StartsWith(WebSocketDeflateConstants.ClientMaxWindowBits))
{
// https://datatracker.ietf.org/doc/html/rfc7692#section-7
// MUST decline if:
// The negotiation offer contains multiple extension parameters with
// the same name.
if (hasClientMaxWindowBits)
{
return false;
}

hasClientMaxWindowBits = true;
if (!ParseWindowBits(value, WebSocketDeflateConstants.ClientMaxWindowBits, out var clientMaxWindowBits))
{
return false;
}

// 8 is a valid value according to the spec, but our zlib implementation does not support it
if (clientMaxWindowBits == 8)
{
return false;
}

// https://tools.ietf.org/html/rfc7692#section-7.1.2.2
// the server may either ignore this
// value or use this value to avoid allocating an unnecessarily big LZ77
// sliding window by including the "client_max_window_bits" extension
// 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));
}
else if (value.StartsWith(WebSocketDeflateConstants.ServerMaxWindowBits))
{
// https://datatracker.ietf.org/doc/html/rfc7692#section-7
// MUST decline if:
// The negotiation offer contains multiple extension parameters with
// the same name.
if (hasServerMaxWindowBits)
{
return false;
}

hasServerMaxWindowBits = true;
if (!ParseWindowBits(value, WebSocketDeflateConstants.ServerMaxWindowBits, out var parsedServerMaxWindowBits))
{
return false;
}

// 8 is a valid value according to the spec, but our zlib implementation does not support it
if (parsedServerMaxWindowBits == 8)
{
return false;
}

// https://tools.ietf.org/html/rfc7692#section-7.1.2.1
// A server accepts an extension negotiation offer with this parameter
// by including the "server_max_window_bits" extension parameter in the
// extension negotiation response to send back to the client with the
// same or smaller value as the offer.
parsedOptions.ServerMaxWindowBits = Math.Min(parsedServerMaxWindowBits ?? 15, serverMaxWindowBits);
}

static bool ParseWindowBits(ReadOnlySpan<char> value, string propertyName, out int? parsedValue)
{
var startIndex = value.IndexOf('=');

// parameters can be sent without a value by the client, we'll use the values set by the app developer or the default of 15
if (startIndex < 0)
{
parsedValue = null;
return true;
}

value = value[(startIndex + 1)..].TrimEnd();

if (value.Length == 0)
{
parsedValue = null;
return false;
}

// https://datatracker.ietf.org/doc/html/rfc7692#section-5.2
// check for value in quotes and pull the value out without the quotes
if (value[0] == '"' && value.EndsWith("\"".AsSpan()) && value.Length > 1)
{
value = value[1..^1];
}

if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int windowBits) ||
windowBits < 8 ||
windowBits > 15)
{
parsedValue = null;
return false;
}

parsedValue = windowBits;
return true;
}

if (end < 0)
{
break;
}
extension = extension[(end + 1)..];
}

if (!parsedOptions.ServerContextTakeover)
{
builder.Append("; ").Append(WebSocketDeflateConstants.ServerNoContextTakeover);
}

if (hasServerMaxWindowBits || parsedOptions.ServerMaxWindowBits != 15)
{
builder.Append("; ")
.Append(WebSocketDeflateConstants.ServerMaxWindowBits).Append('=')
.Append(parsedOptions.ServerMaxWindowBits.ToString(CultureInfo.InvariantCulture));
}

response = builder.ToString();

return true;
}
}
}
Loading