Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 213 additions & 7 deletions src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Security;
using System.Threading;
Expand Down Expand Up @@ -186,7 +187,7 @@
await args.SyphonOutBodyAsync(true, cancellationToken);

// A BeforeRequest-time synthetic response (Ok/Redirect/GenericResponse/etc.) has
// already locked the response and made its one BeforeResponse-equivalent decision;

Check warning on line 190 in src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs

View workflow job for this annotation

GitHub Actions / build

Remove this commented out code.
// do not give it a second BeforeResponse pass (mirrors HandleHttpSessionResponse's
// own `if (!response.Locked)` guard).
if (!args.HttpClient.Response.Locked) await OnBeforeResponse(args);
Expand All @@ -197,13 +198,39 @@
}
else if (keepGoing && request.UpgradeToWebSocket)
{
// WebSocket-over-h2 (RFC 8441 extended CONNECT) is not implemented in this
// version; report a clean, defined failure rather than attempting a translation
// that cannot succeed.
args.GenericResponse(
"WebSocket upgrade is not supported when the origin connection is HTTP/2.",
HttpStatusCode.NotImplemented);
await clientStream.WriteResponseAsync(args.HttpClient.Response, cancellationToken);
// Opt-in RFC 8441 bridge for HTTP/1.1 Upgrade onto an h2 origin.
// With EnableRfc8441 off, keep the historical synthetic 501.
if (!EnableRfc8441)
{
args.GenericResponse(
"WebSocket upgrade is not supported when the origin connection is HTTP/2.",
HttpStatusCode.NotImplemented);
await clientStream.WriteResponseAsync(args.HttpClient.Response, cancellationToken);
}
else
{
if (originConnection == null || !originConnection.IsUsable)
{
originConnection?.Dispose();
originConnection = await AcquireHttp2OriginConnectionAsync(args, remoteHostName,
remotePort, connectHost, connectPort, retainedConnectionTask,
cancellationToken);
retainedConnectionTask = null;
}

if (originConnection.EnableConnectProtocol)
{
await RunHttp11ToHttp2WebSocketTunnelAsync(args, originConnection,
cancellationTokenSource, cancellationToken);
}
else
{
// h2 origin without ENABLE_CONNECT_PROTOCOL: dedicated HTTP/1.1 fallback.
await RunHttp11WebSocketHttp11FallbackAsync(args, remoteHostName, remotePort,
connectHost, connectPort, cancellationTokenSource, cancellationToken);
}
}

closeConnection = true;
keepGoing = false;
}
Expand Down Expand Up @@ -522,6 +549,185 @@
response.IsBodySent = true;
}

private async Task RunHttp11ToHttp2WebSocketTunnelAsync(SessionEventArgs args,
Http2OriginConnection originConnection, CancellationTokenSource cancellationTokenSource,
CancellationToken cancellationToken)
{
var request = args.HttpClient.Request;
var clientStream = args.ClientStream;

var wsKey = request.Headers.GetHeaderValueOrNull("Sec-WebSocket-Key");
if (string.IsNullOrEmpty(wsKey))
{
args.GenericResponse("WebSocket upgrade requires a Sec-WebSocket-Key header.",
HttpStatusCode.BadRequest);
await clientStream.WriteResponseAsync(args.HttpClient.Response, cancellationToken);
return;
}

// Match HandleWebSocketUpgrade: strip extensions when frame/data interception is active so
// permessage-deflate never reaches WebSocketDecoder as opaque compressed bytes.
if (args.HasWebSocketFrameInterceptHandler || args.HasWebSocketDataTapHandler)
request.Headers.RemoveHeader("Sec-WebSocket-Extensions");

var serverConnection = originConnection.ServerConnection;
args.HttpClient.BindUpstreamConnection(serverConnection);
if (args.Timing != null)
args.Timing.MarkConnectionReady(serverConnection.Id, !serverConnection.ClaimFirstUse());

PrepareWebSocketUpgradeForHttp2Origin(request);
args.Timing?.MarkRequestSent();

var tunnelResult = await OpenWebSocketTunnelOrBadGatewayAsync(args, originConnection, cancellationToken);
if (tunnelResult == null) return;

args.Timing?.MarkResponseHeadersReceived();

if (!tunnelResult.IsEstablished || tunnelResult.Stream == null)
{
await WriteRejectedTunnelResponseAsync(args, tunnelResult.Response, cancellationToken);
return;
}

using var tunnelStream = tunnelResult.Stream;
var response101 = BuildSwitchingProtocolsResponse(wsKey, tunnelResult.Response);
args.HttpClient.Response = response101;
if (!args.HttpClient.Response.Locked) await OnBeforeResponse(args);

var response = args.HttpClient.Response;
var userReplacedResponse = response.Locked;
response.Locked = true;

await clientStream.WriteResponseAsync(response, cancellationToken);
args.IsClientResponseCommitted = true;
args.Timing?.MarkComplete();

if (userReplacedResponse) return;

if (args.HasWebSocketFrameInterceptHandler)
{
await WebSocketInterceptRelay.RelayAsync(clientStream, tunnelStream, BufferPool, args,
cancellationTokenSource);
}
else
{
await TcpHelper.SendRaw(clientStream, tunnelStream, BufferPool, args.OnDataSent, args.OnDataReceived,
cancellationTokenSource, logger);
}
}

private async Task<Http2OriginTunnelResult?> OpenWebSocketTunnelOrBadGatewayAsync(SessionEventArgs args,

Check warning on line 619 in src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make 'OpenWebSocketTunnelOrBadGatewayAsync' a static method.

See more on https://sonarcloud.io/project/issues?id=justcoding121_titanium-web-proxy&issues=AZ_CKuo7HHafFaidovzT&open=AZ_CKuo7HHafFaidovzT&pullRequest=970

Check warning on line 619 in src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'OpenWebSocketTunnelOrBadGatewayAsync' does not access instance data and can be marked as static

See more on https://sonarcloud.io/project/issues?id=justcoding121_titanium-web-proxy&issues=AZ_CKuo7HHafFaidovzU&open=AZ_CKuo7HHafFaidovzU&pullRequest=970
Http2OriginConnection originConnection, CancellationToken cancellationToken)
{
try
{
return await originConnection.OpenTunnelAsync(args.HttpClient.Request, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
if (!args.HttpClient.Response.Locked)
{
args.GenericResponse($"Bad Gateway. {ex.Message}", HttpStatusCode.BadGateway);
await args.ClientStream.WriteResponseAsync(args.HttpClient.Response, cancellationToken);
}

return null;
}
}

private async Task WriteRejectedTunnelResponseAsync(SessionEventArgs args, Response rejected,
CancellationToken cancellationToken)
{
rejected.HttpVersion = HttpHeader.Version11;
args.HttpClient.Response = rejected;
if (!rejected.Locked) await OnBeforeResponse(args);
await args.ClientStream.WriteResponseAsync(args.HttpClient.Response, cancellationToken);
}

private static Response BuildSwitchingProtocolsResponse(string wsKey, Response originResponse)
{
var response101 = new Response
{
HttpVersion = HttpHeader.Version11,
StatusCode = 101,
StatusDescription = "Switching Protocols"
};
response101.Headers.AddHeader(KnownHeaders.Upgrade, KnownHeaders.UpgradeWebsocket);
response101.Headers.AddHeader(KnownHeaders.Connection, "Upgrade");
response101.Headers.AddHeader("Sec-WebSocket-Accept", WebSocketHandshake.ComputeAccept(wsKey));

foreach (var name in new[] { "sec-websocket-protocol", "sec-websocket-extensions" })
{
foreach (var header in originResponse.Headers.GetHeaders(name) ?? Enumerable.Empty<HttpHeader>())
response101.Headers.AddHeader(header.Name, header.Value);
}

return response101;
}

private async Task RunHttp11WebSocketHttp11FallbackAsync(SessionEventArgs args, string remoteHostName,
int remotePort, string? connectHost, int? connectPort, CancellationTokenSource cancellationTokenSource,
CancellationToken cancellationToken)
{
var customUpStreamProxy = args.CustomUpStreamProxy;
if (customUpStreamProxy == null && GetCustomUpStreamProxyFunc != null)
customUpStreamProxy = await GetCustomUpStreamProxyFunc(args);
args.CustomUpStreamProxyUsed = customUpStreamProxy;

var isHttps = args.HttpClient.Request.IsHttps;
var connection = await TcpConnectionFactory.GetServerConnection(this, remoteHostName, remotePort,
HttpHeader.Version11, isHttps, SslExtensions.Http11ProtocolAsList, false, args,
args.HttpClient.UpStreamEndPoint ?? UpStreamEndPoint, customUpStreamProxy ?? UpStreamHttpsProxy, true,
false, cancellationToken, connectHost, connectPort)
?? throw new ProxyHttpException(
$"Failed to establish an HTTP/1.1 connection to '{remoteHostName}:{remotePort}' for WebSocket " +
"fallback from the HTTP/1.1-to-HTTP/2 bridge.", null, args);

try
{
args.HttpClient.SetConnection(connection);
await HandleWebSocketUpgrade(args, args.ClientStream, connection, cancellationTokenSource,
cancellationToken);
}
finally
{
await TcpConnectionFactory.Release(connection, true);
}
}

/// <summary>
/// Translates an HTTP/1.1 WebSocket Upgrade request into an RFC 8441 extended CONNECT suitable for
/// an h2 origin: <c>CONNECT</c> + <c>:protocol=websocket</c>, with hop-by-hop / superseded fields
/// removed per RFC 8441 §5.
/// </summary>
private static void PrepareWebSocketUpgradeForHttp2Origin(Request request)
{
if (request.Authority.Length == 0)
{
var hostHeader = request.Host;
if (!string.IsNullOrEmpty(hostHeader)) request.Authority = hostHeader.GetByteString();
}

request.Method = "CONNECT";
request.ExtendedConnectProtocol = "websocket";
// Keep the client's HTTP/1.1 version on the SessionEventArgs request so synthetic
// BeforeResponse replacements (GenericResponse/etc.) still speak HTTP/1.1 to the client.
// SendHeader does not require HttpVersion 2.0 on the Request object.

request.Headers.RemoveHeader(KnownHeaders.Connection);
request.Headers.RemoveHeader("Keep-Alive");
request.Headers.RemoveHeader(KnownHeaders.ProxyConnection);
request.Headers.RemoveHeader(KnownHeaders.TransferEncoding);
request.Headers.RemoveHeader(KnownHeaders.Upgrade);
request.Headers.RemoveHeader("TE");
request.Headers.RemoveHeader(KnownHeaders.Host);
// Superseded by :protocol (RFC 8441 §5); Sec-WebSocket-Accept is response-only.
request.Headers.RemoveHeader("Sec-WebSocket-Key");
request.Headers.RemoveHeader("Sec-WebSocket-Accept");

LowercaseHeaderNames(request.Headers);
}

/// <summary>
/// Strips hop-by-hop/connection-specific header fields (RFC 7540 §8.1.2.2) that an HTTP/1.1 client may
/// legitimately send but that an h2 origin forbids, and lowercases every remaining field name (RFC
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,7 @@ await Http2Helper.EmitSyntheticResponseAsync(sessionArgs, ctx.StreamId,

var upgrade = upgradeResponseHeaders.GetFirstHeader("Upgrade")?.Value;
var responseConnection = upgradeResponseHeaders.GetFirstHeader("Connection")?.Value;
var expectedAccept = Convert.ToBase64String(SHA1.HashData(
Encoding.ASCII.GetBytes(wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
var expectedAccept = WebSocketHandshake.ComputeAccept(wsKey);
var actualAccept = upgradeResponseHeaders.GetFirstHeader("Sec-WebSocket-Accept")?.Value;
if (!string.Equals(actualAccept, expectedAccept, StringComparison.Ordinal) ||
!string.Equals(upgrade, "websocket", StringComparison.OrdinalIgnoreCase) ||
Expand Down
Loading
Loading