diff --git a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs index 1db3710e..b5410a1a 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Net; using System.Net.Security; using System.Threading; @@ -197,13 +198,39 @@ internal async Task SendHttp11ToHttp2Bridge(HttpClientStream clientStream, Proxy } 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; } @@ -522,6 +549,185 @@ await clientStream.WriteBodyAsync(body, response.IsChunked, 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 OpenWebSocketTunnelOrBadGatewayAsync(SessionEventArgs args, + 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()) + 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); + } + } + + /// + /// Translates an HTTP/1.1 WebSocket Upgrade request into an RFC 8441 extended CONNECT suitable for + /// an h2 origin: CONNECT + :protocol=websocket, with hop-by-hop / superseded fields + /// removed per RFC 8441 §5. + /// + 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); + } + /// /// 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 diff --git a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs index 3ba3660b..162d1f9c 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs @@ -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) || diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs index 75acdd2a..0f530726 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs @@ -94,6 +94,12 @@ private Http2OriginConnection(TcpServerConnection connection, ILogger logger, lo /// True while this connection may still be leased for a new request. internal bool IsUsable => !faulted && !goingAway && !connection.IsClosed; + /// + /// Whether the origin advertised SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441). + /// Valid only after the initial SETTINGS exchange has completed. + /// + internal bool EnableConnectProtocol => originSettings.EnableConnectProtocol; + /// /// The underlying TCP connection, exposed so callers can attribute /// timing (connection id, reuse, and @@ -243,6 +249,220 @@ await Http2Helper.SendTrailer(originSettings, frameHeader, frameHeaderBuffer, st } } + /// + /// Opens an RFC 8441 extended CONNECT tunnel on a freshly leased stream. The request must already + /// have Method = CONNECT and set (and hop-by-hop + /// headers stripped). On a final 2xx response, returns an that + /// speaks raw DATA for the life of the tunnel; on any other status, resets the stream and returns + /// the response headers without a stream. + /// + internal async Task OpenTunnelAsync(Request request, + CancellationToken cancellationToken) + { + if (!IsUsable) throw new Http2OriginGoAwayException("The origin h2 connection is no longer usable."); + + await initialSettingsReceived.Task.WaitAsync(cancellationToken); + + if (!originSettings.EnableConnectProtocol) + { + throw new InvalidOperationException( + "The origin did not advertise SETTINGS_ENABLE_CONNECT_PROTOCOL=1; " + + "extended CONNECT cannot be opened on this connection."); + } + + var gate = concurrencyGate ?? throw new InvalidOperationException("Origin settings were never processed."); + await gate.WaitAsync(cancellationToken); + + var streamId = Interlocked.Add(ref lastStreamId, 2); + var pending = PendingStream.CreateTunnel(); + + if (goingAway && streamId > goAwayLastStreamId) + { + gate.Release(); + pending.Dispose(); + throw new Http2OriginGoAwayException( + $"The origin sent GOAWAY before stream {streamId} could be opened; it was never processed."); + } + + streams[streamId] = pending; + sendFlow.RegisterStream(streamId); + + try + { + var frameHeader = new Http2FrameHeader { StreamId = streamId }; + var frameHeaderBuffer = new byte[9]; + + await writeLock.WaitAsync(cancellationToken); + try + { + // Must use SendHeader with endStream=false: SendBody derives END_STREAM from the body + // and would half-close a bodiless CONNECT before the first tunnel byte. + await Http2Helper.SendHeader(originSettings, frameHeader, frameHeaderBuffer, request, + endStream: false, stream, pushPromise: false); + } + finally + { + writeLock.Release(); + } + } + catch + { + streams.TryRemove(streamId, out _); + pending.Dispose(); + sendFlow.RemoveStream(streamId); + gate.Release(); + throw; + } + + try + { + await using var registration = cancellationToken.Register(() => + { + pending.HeadersReceived.TrySetCanceled(cancellationToken); + pending.TunnelDataChannel?.Writer.TryComplete(new OperationCanceledException(cancellationToken)); + }); + + await pending.HeadersReceived.Task.WaitAsync(cancellationToken); + + var response = pending.Response ?? + new Response + { + StatusCode = 502, StatusDescription = string.Empty, + HttpVersion = HttpHeader.Version11 + }; + + if (response.StatusCode is < 200 or >= 300) + { + await ResetStreamAsync(streamId, Http2ErrorCode.Cancel, CancellationToken.None); + ReleaseTunnelBookkeeping(streamId, pending, gate); + return new Http2OriginTunnelResult(response, null); + } + + var tunnelStream = new Http2TunnelStream( + pending.TunnelDataChannel!.Reader, + (payload, endStream, ct) => WriteTunnelDataAsync(streamId, payload, endStream, ct), + (errorCode, ct) => ResetStreamAsync(streamId, errorCode, ct), + () => ReleaseTunnelBookkeeping(streamId, pending, gate)); + + // Ownership of gate/sendFlow/pending transfers to the tunnel stream until Dispose. + return new Http2OriginTunnelResult(response, tunnelStream); + } + catch + { + try + { + await ResetStreamAsync(streamId, Http2ErrorCode.Cancel, CancellationToken.None); + } + catch + { + // best-effort + } + + ReleaseTunnelBookkeeping(streamId, pending, gate); + throw; + } + } + + private async Task WriteTunnelDataAsync(int streamId, ReadOnlyMemory payload, bool endStream, + CancellationToken cancellationToken) + { + if (!streams.ContainsKey(streamId) && !endStream) + throw new IOException($"HTTP/2 tunnel stream {streamId} is no longer open."); + + var frameHeader = new Http2FrameHeader { StreamId = streamId }; + var frameHeaderBuffer = new byte[9]; + var offset = 0; + + await writeLock.WaitAsync(cancellationToken); + try + { + if (payload.Length == 0) + { + if (!endStream) return; + + frameHeader.Length = 0; + frameHeader.Type = Http2FrameType.Data; + frameHeader.Flags = Http2FrameFlag.EndStream; + frameHeader.CopyToBuffer(frameHeaderBuffer); + await stream.WriteAsync(frameHeaderBuffer, cancellationToken); + await stream.FlushAsync(cancellationToken); + return; + } + + while (offset < payload.Length) + { + var frameLength = Math.Min(SafeMaxFrameSize, payload.Length - offset); + await sendFlow.ReserveAsync(streamId, frameLength, cancellationToken); + + frameHeader.Length = frameLength; + frameHeader.Type = Http2FrameType.Data; + var isLast = offset + frameLength >= payload.Length; + frameHeader.Flags = isLast && endStream ? Http2FrameFlag.EndStream : 0; + frameHeader.CopyToBuffer(frameHeaderBuffer); + + await stream.WriteAsync(frameHeaderBuffer, cancellationToken); + await stream.WriteAsync(payload.Slice(offset, frameLength), cancellationToken); + offset += frameLength; + } + + if (endStream && payload.Length == 0) + { + // handled above + } + + await stream.FlushAsync(cancellationToken); + } + finally + { + writeLock.Release(); + } + } + + private async Task ResetStreamAsync(int streamId, Http2ErrorCode errorCode, + CancellationToken cancellationToken) + { + var frameHeader = new Http2FrameHeader(); + var frameHeaderBuffer = new byte[9]; + + await writeLock.WaitAsync(cancellationToken); + try + { + await Http2Helper.SendRstStreamAsync(frameHeader, frameHeaderBuffer, streamId, errorCode, stream); + await stream.FlushAsync(cancellationToken); + } + finally + { + writeLock.Release(); + } + } + + private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, SemaphoreSlim gate) + { + if (streams.TryRemove(streamId, out var removed)) + { + removed.TunnelDataChannel?.Writer.TryComplete(); + removed.Dispose(); + } + else + { + pending.Dispose(); + } + + sendFlow.RemoveStream(streamId); + try + { + gate.Release(); + } + catch (ObjectDisposedException) + { + // connection already torn down + } + catch (SemaphoreFullException) + { + // already released + } + } + private async Task SendSettingsAckAsync(CancellationToken cancellationToken) { var frameHeader = new Http2FrameHeader @@ -489,20 +709,40 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken) case Http2FrameType.Data: { var data = StripDataFraming(payload, flags); - if (data.Length > 0 && streams.TryGetValue(streamId, out var pendingData)) + if (streams.TryGetValue(streamId, out var pendingData)) { - try + if (pendingData.IsTunnel) { - await pendingData.BodyPipe.WriteAsync(data.AsMemory(), cancellationToken); - } - catch (BodySizeLimitExceededException) - { - // WriteAsync already faulted the pipe writer; CopyToAsync in SendAsync will - // propagate the exception. Continue the read loop for other streams. + if (data.Length > 0) + { + // Bounded channel provides backpressure; drop only if the tunnel is + // already tearing down (writer completed). + try + { + await pendingData.TunnelDataChannel!.Writer + .WriteAsync(data, cancellationToken); + } + catch (ChannelClosedException) + { + // Tunnel already closed; ignore stale DATA. + } + } } - catch (InvalidOperationException) + else if (data.Length > 0) { - // Writer already completed (cancelled or stream failed); ignore stale frames. + try + { + await pendingData.BodyPipe.WriteAsync(data.AsMemory(), cancellationToken); + } + catch (BodySizeLimitExceededException) + { + // WriteAsync already faulted the pipe writer; CopyToAsync in SendAsync will + // propagate the exception. Continue the read loop for other streams. + } + catch (InvalidOperationException) + { + // Writer already completed (cancelled or stream failed); ignore stale frames. + } } } @@ -536,10 +776,7 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken) { var goAwayEx = new Http2OriginGoAwayException( $"The origin sent GOAWAY before stream {kvp.Key} was processed; it is safe to retry."); - kvp.Value.BodyPipe.CompleteWriter(goAwayEx); - // Also unblock any SendAsync that is awaiting the interim channel, - // since no response frames (including 1xx) will ever arrive for this stream. - kvp.Value.InterimChannel.Writer.TryComplete(goAwayEx); + FailPending(kvp.Value, goAwayEx); } } } @@ -605,9 +842,40 @@ private void ApplySettings(byte[] payload) } else if (identifier == (int)Http2SettingsId.MaxConcurrentStreams) originSettings.MaxConcurrentStreams = value; + else if (identifier == (int)Http2SettingsId.EnableConnectProtocol) + ApplyEnableConnectProtocolSetting(value); } } + /// RFC 8441 §3: value MUST be 0 or 1; a sender MUST NOT send 0 after previously sending 1. + private void ApplyEnableConnectProtocolSetting(int value) + { + var error = ValidateEnableConnectProtocolSetting(value, originSettings.EnableConnectProtocolEverSet); + if (error != null) + { + Fail(new IOException(error)); + return; + } + + originSettings.EnableConnectProtocol = value == 1; + if (value == 1) originSettings.EnableConnectProtocolEverSet = true; + } + + /// + /// Returns a protocol-error message when is illegal for + /// SETTINGS_ENABLE_CONNECT_PROTOCOL; otherwise null. + /// + internal static string? ValidateEnableConnectProtocolSetting(int value, bool previouslyEnabled) + { + if (value is not (0 or 1)) + return $"HTTP/2 protocol error: SETTINGS_ENABLE_CONNECT_PROTOCOL value {value} is not 0 or 1."; + + if (value == 0 && previouslyEnabled) + return "HTTP/2 protocol error: SETTINGS_ENABLE_CONNECT_PROTOCOL must not be downgraded from 1 to 0."; + + return null; + } + /// Strips the optional PADDED (1 length byte + trailing padding) and PRIORITY (5 bytes) framing from a HEADERS frame payload. private static byte[] StripHeadersFraming(byte[] payload, Http2FrameFlag flags) { @@ -688,14 +956,26 @@ private void ProcessHeaderBlock(int streamId, byte[] compressed, bool endStream) pending.Response = response; // Signal that no more interim responses will arrive; unblocks SendAsync's interim drain loop. pending.InterimChannel.Writer.TryComplete(); + // Unblock OpenTunnelAsync waiting on the final response headers. + pending.HeadersReceived.TrySetResult(true); } } else { // A HEADERS block without a ":status" pseudo-header, following the main response headers, is a // trailer block (RFC 7540 §8.1.2.1 / RFC 7230 §4.1.2). - pending.TrailingHeaders ??= new HeaderCollection(); - foreach (var header in collected) pending.TrailingHeaders.AddHeader(header); + // RFC 9113 §8.5: trailers on an established extended CONNECT tunnel are a protocol error — + // complete the inbound side so the tunnel reader observes EOF rather than hanging. + if (pending.IsTunnel) + { + pending.TunnelDataChannel?.Writer.TryComplete( + new IOException("HTTP/2 protocol error: HEADERS received on an established extended CONNECT tunnel.")); + } + else + { + pending.TrailingHeaders ??= new HeaderCollection(); + foreach (var header in collected) pending.TrailingHeaders.AddHeader(header); + } } if (endStream) CompleteStream(streamId); @@ -703,8 +983,17 @@ private void ProcessHeaderBlock(int streamId, byte[] compressed, bool endStream) private void CompleteStream(int streamId) { + if (!streams.TryGetValue(streamId, out var pending)) return; + + if (pending.IsTunnel) + { + // Keep the stream registered so the tunnel can still write outbound DATA; just half-close inbound. + pending.TunnelDataChannel?.Writer.TryComplete(); + return; + } + // Use TryRemove so subsequent DATA frames for this stream-id are ignored in the read loop. - if (!streams.TryRemove(streamId, out var pending)) return; + if (!streams.TryRemove(streamId, out pending)) return; pending.BodyPipe.CompleteWriter(); } @@ -712,11 +1001,15 @@ private void FailStream(int streamId, Exception ex) { // Use TryRemove so subsequent DATA frames for this stream are ignored in the read loop. if (streams.TryRemove(streamId, out var pending)) - { - pending.BodyPipe.CompleteWriter(ex); - // Unblock any SendAsync that is awaiting interim responses (e.g. RST_STREAM while draining 1xx). - pending.InterimChannel.Writer.TryComplete(ex); - } + FailPending(pending, ex); + } + + private static void FailPending(PendingStream pending, Exception ex) + { + pending.BodyPipe.CompleteWriter(ex); + pending.InterimChannel.Writer.TryComplete(ex); + pending.TunnelDataChannel?.Writer.TryComplete(ex); + pending.HeadersReceived.TrySetException(ex); } /// The failure to fault every in-flight/future stream with. @@ -738,11 +1031,7 @@ private void Fail(Exception ex, bool report = true) new ProxyHttpException("The HTTP/1.1-to-HTTP/2 origin bridge connection failed.", ex, null)); foreach (var kvp in streams) - { - kvp.Value.BodyPipe.CompleteWriter(ex); - // Unblock any SendAsync that is awaiting interim responses; no more frames will ever arrive. - kvp.Value.InterimChannel.Writer.TryComplete(ex); - } + FailPending(kvp.Value, ex); initialSettingsReceived.TrySetException(ex); } @@ -783,6 +1072,7 @@ private async Task ForceReadAsync(byte[] buffer, int offset, int bytesToRea private sealed class PendingStream : IDisposable { internal readonly BoundedBodyPipe BodyPipe; + internal readonly bool IsTunnel; /// /// Queue of 1xx interim responses written by as they arrive from @@ -794,16 +1084,51 @@ private sealed class PendingStream : IDisposable Channel.CreateUnbounded<(int, HeaderCollection)>( new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); + /// + /// Completed when the final (non-1xx) response HEADERS arrive. Used by + /// ; ordinary ignores it. + /// + internal readonly TaskCompletionSource HeadersReceived = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Inbound DATA payloads for an RFC 8441 tunnel. Null for ordinary request/response streams, + /// which use instead (and enforce MaxBufferedBodyBytes). + /// + internal readonly Channel? TunnelDataChannel; + internal Response? Response; internal HeaderCollection? TrailingHeaders; - internal PendingStream(long maxBodyBytes = 0) => BodyPipe = new BoundedBodyPipe(maxBodyBytes); + internal PendingStream(long maxBodyBytes = 0) + { + IsTunnel = false; + BodyPipe = new BoundedBodyPipe(maxBodyBytes); + } + + private PendingStream(bool isTunnel) + { + IsTunnel = isTunnel; + // Tunnel streams never buffer a finite HTTP body; BodyPipe is unused but kept non-null + // so FailPending can CompleteWriter unconditionally. + BodyPipe = new BoundedBodyPipe(0); + TunnelDataChannel = Channel.CreateBounded(new BoundedChannelOptions(256) + { + SingleReader = true, + SingleWriter = true, + FullMode = BoundedChannelFullMode.Wait + }); + } + + internal static PendingStream CreateTunnel() => new(true); public void Dispose() { BodyPipe.Dispose(); // Release any reader blocking on WaitToReadAsync if Dispose is called without a prior Complete. InterimChannel.Writer.TryComplete(); + TunnelDataChannel?.Writer.TryComplete(); + HeadersReceived.TrySetCanceled(); } } @@ -839,3 +1164,21 @@ internal Http2OriginExchange(Response response, byte[] body, HeaderCollection? t internal HeaderCollection? TrailingHeaders { get; } } + +/// +/// Result of . When the origin accepts the +/// extended CONNECT (), is the duplex tunnel; +/// otherwise is null and carries the rejection. +/// +internal sealed class Http2OriginTunnelResult +{ + internal Http2OriginTunnelResult(Response response, Http2TunnelStream? stream) + { + Response = response; + Stream = stream; + } + + internal Response Response { get; } + internal Http2TunnelStream? Stream { get; } + internal bool IsEstablished => Stream != null && Response.StatusCode is >= 200 and < 300; +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2TunnelStream.cs b/src/Titanium.Web.Proxy/Http2/Http2TunnelStream.cs new file mode 100644 index 00000000..615d7013 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2TunnelStream.cs @@ -0,0 +1,192 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace Titanium.Web.Proxy.Http2; + +/// +/// Exposes one leased HTTP/2 stream on an as a duplex +/// , so existing WebSocket relay machinery (TcpHelper.SendRaw, +/// WebSocketInterceptRelay) can treat an RFC 8441 extended CONNECT tunnel as if it were a +/// TCP connection (RFC 8441 §5). +/// +internal sealed class Http2TunnelStream : Stream +{ + private readonly ChannelReader inbound; + private readonly Func, bool, CancellationToken, Task> writeDataAsync; + private readonly Func resetStreamAsync; + private readonly Action onDisposed; + + private byte[]? pending; + private int pendingOffset; + private bool inboundCompleted; + private bool writeEndStreamSent; + private bool disposed; + private int disposeStarted; + + internal Http2TunnelStream( + ChannelReader inbound, + Func, bool, CancellationToken, Task> writeDataAsync, + Func resetStreamAsync, + Action onDisposed) + { + this.inbound = inbound; + this.writeDataAsync = writeDataAsync; + this.resetStreamAsync = resetStreamAsync; + this.onDisposed = onDisposed; + } + + public override bool CanRead => !disposed; + public override bool CanSeek => false; + public override bool CanWrite => !disposed && !writeEndStreamSent; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException("Use ReadAsync."); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException("Use WriteAsync."); + + public override async Task ReadAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (count == 0) return 0; + + while (true) + { + var copied = TryCopyPending(buffer, offset, count); + if (copied > 0) return copied; + if (inboundCompleted) return 0; + + if (!await FillPendingAsync(cancellationToken).ConfigureAwait(false)) + return 0; + } + } + + private int TryCopyPending(byte[] buffer, int offset, int count) + { + if (pending == null || pending.Length <= pendingOffset) return 0; + + var available = pending.Length - pendingOffset; + var toCopy = Math.Min(count, available); + Buffer.BlockCopy(pending, pendingOffset, buffer, offset, toCopy); + pendingOffset += toCopy; + if (pendingOffset >= pending.Length) + { + pending = null; + pendingOffset = 0; + } + + return toCopy; + } + + /// when the inbound side is exhausted (EOF). + private async Task FillPendingAsync(CancellationToken cancellationToken) + { + if (!await inbound.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + inboundCompleted = true; + return false; + } + + while (inbound.TryRead(out var chunk)) + { + if (chunk.Length == 0) continue; + pending = chunk; + pendingOffset = 0; + return true; + } + + if (inbound.Completion.IsCompleted) + { + inboundCompleted = true; + return false; + } + + return true; + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (writeEndStreamSent) + throw new InvalidOperationException("The HTTP/2 tunnel stream has already been half-closed for writes."); + if (count == 0) return; + + await writeDataAsync(buffer.AsMemory(offset, count), false, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sends an empty DATA frame with END_STREAM to half-close the write direction without + /// disposing the stream (readers may still drain inbound DATA). + /// + internal async Task CompleteWriteAsync(CancellationToken cancellationToken) + { + if (disposed || writeEndStreamSent) return; + writeEndStreamSent = true; + await writeDataAsync(ReadOnlyMemory.Empty, true, cancellationToken).ConfigureAwait(false); + } + + protected override void Dispose(bool disposing) + { + if (Interlocked.Exchange(ref disposeStarted, 1) != 0) return; + + if (disposing) + { + disposed = true; + // Never block Dispose on the origin write lock (the read loop also takes it for + // WINDOW_UPDATE / SETTINGS ACK). Tear down asynchronously and always release bookkeeping. + _ = TeardownAsync(); + } + + base.Dispose(disposing); + } + + private async Task TeardownAsync() + { + try + { + if (!writeEndStreamSent) + { + writeEndStreamSent = true; + await writeDataAsync(ReadOnlyMemory.Empty, true, CancellationToken.None) + .ConfigureAwait(false); + } + } + catch + { + try + { + await resetStreamAsync(Http2ErrorCode.Cancel, CancellationToken.None).ConfigureAwait(false); + } + catch + { + // best-effort teardown + } + } + finally + { + onDisposed(); + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Rfc8441Design.md b/src/Titanium.Web.Proxy/Http2/Rfc8441Design.md index f1dd9e5a..35af989c 100644 --- a/src/Titanium.Web.Proxy/Http2/Rfc8441Design.md +++ b/src/Titanium.Web.Proxy/Http2/Rfc8441Design.md @@ -64,7 +64,25 @@ HTTP/1.1 (`Upgrade: websocket`) requests. |---|---|---| | HTTP/2 extended CONNECT | HTTP/2 extended CONNECT | Native h2↔h2 DATA relay (no translation) | | HTTP/2 extended CONNECT | HTTP/1.1 | h2→h1 bridge: translate 200→WebSocket upgrade, relay DATA as WebSocket frames | -| HTTP/1.1 Upgrade | HTTP/2 | Not supported — synthetic `501 Not Implemented` (`Http11ToHttp2BridgeHandler`) | +| HTTP/1.1 Upgrade | HTTP/2 | When `EnableRfc8441=true`: translate to extended CONNECT on the h2 origin (`Http11ToHttp2BridgeHandler` + `Http2TunnelStream`); if the origin does not advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL=1`, fall back to a dedicated HTTP/1.1 origin connection and reuse `HandleWebSocketUpgrade`. When `EnableRfc8441=false` (default): synthetic `501 Not Implemented` | + +### HTTP/1.1 Upgrade → HTTP/2 origin lifecycle + +1. **Gate**: only reached on the H1→H2 translation bridge (`UpstreamHttpProtocol.Http2` + + `AllowHttpProtocolTranslation`) when the client sends `Upgrade: websocket`. With + `EnableRfc8441=false`, the historical synthetic `501` is preserved. +2. **Capability check**: after the origin's initial SETTINGS, if `EnableConnectProtocol` is true, + the proxy translates the Upgrade into `CONNECT` + `:protocol=websocket` (stripping + `Connection`/`Upgrade`/`Sec-WebSocket-Key`/`Host` per RFC 8441 §5) and opens a tunnel stream via + `Http2OriginConnection.OpenTunnelAsync`. +3. **Client 101**: on origin 2xx, the proxy synthesizes `101 Switching Protocols` with a locally + computed `Sec-WebSocket-Accept` and any negotiated `Sec-WebSocket-Protocol` / + `Sec-WebSocket-Extensions` from the origin, then runs `BeforeResponse`. +4. **DATA relay**: the leased h2 stream is exposed as `Http2TunnelStream` so + `TcpHelper.SendRaw` / `WebSocketInterceptRelay` treat it as a TCP connection (RFC 8441 §5). +5. **Fallback**: if the h2 origin does not advertise the setting, the proxy opens a dedicated + HTTP/1.1 origin connection for that WebSocket only and reuses `HandleWebSocketUpgrade` + (RFC 8441 §7 intermediary behavior). ### Native h2↔h2 tunnel lifecycle diff --git a/src/Titanium.Web.Proxy/ProxyServer.cs b/src/Titanium.Web.Proxy/ProxyServer.cs index 5235d5fd..da61f3e2 100644 --- a/src/Titanium.Web.Proxy/ProxyServer.cs +++ b/src/Titanium.Web.Proxy/ProxyServer.cs @@ -306,14 +306,29 @@ internal void TrimOriginCapabilityCaches() public bool EnableHttp2 { get; set; } = true; /// - /// When , the proxy accepts WebSocket-over-HTTP/2 connections from - /// clients (RFC 8441 extended CONNECT with :protocol = websocket) and advertises - /// SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to h2 clients. The proxy independently - /// negotiates with each origin: if the origin is HTTP/2 and advertises RFC 8441 support, - /// DATA frames are relayed directly; if the origin is HTTP/2 and does not, the stream is - /// reset with REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade - /// bridge is used. - /// Default: (must opt-in; demand measurement pending). + /// When , the proxy enables RFC 8441 WebSocket-over-HTTP/2: + /// + /// + /// + /// Accepts extended CONNECT (:protocol = websocket) from h2 clients and + /// advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to them. Per origin: if the + /// origin is HTTP/2 and advertises RFC 8441 support, DATA frames are relayed directly; + /// if the origin is HTTP/2 and does not, the stream is reset with + /// REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge + /// is used. + /// + /// + /// + /// + /// On the HTTP/1.1-client-to-h2-origin translation bridge, translates + /// Upgrade: websocket into extended CONNECT when the origin advertises the + /// setting; otherwise falls back to a dedicated HTTP/1.1 origin connection for that + /// WebSocket. When this property is , that bridge still returns + /// synthetic 501 Not Implemented for WebSocket upgrades (historical default). + /// + /// + /// + /// Default: (must opt-in). /// public bool EnableRfc8441 { get; set; } = false; diff --git a/src/Titanium.Web.Proxy/WebSocket/WebSocketHandshake.cs b/src/Titanium.Web.Proxy/WebSocket/WebSocketHandshake.cs new file mode 100644 index 00000000..b730ab72 --- /dev/null +++ b/src/Titanium.Web.Proxy/WebSocket/WebSocketHandshake.cs @@ -0,0 +1,27 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; + +namespace Titanium.Web.Proxy; + +/// +/// Shared WebSocket opening-handshake helpers (RFC 6455). +/// +internal static class WebSocketHandshake +{ + private const string AcceptGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + /// + /// Computes Sec-WebSocket-Accept from the client's Sec-WebSocket-Key + /// (RFC 6455 §1.3). SHA-1 is mandatory for this value; it is not used as a general digest. + /// + [SuppressMessage("Major Vulnerability", "S4790:Using weak hashing algorithms is security-sensitive", + Justification = "RFC 6455 §1.3 requires SHA-1 for Sec-WebSocket-Accept; this is not a general-purpose hash.")] + internal static string ComputeAccept(string secWebSocketKey) + { + ArgumentException.ThrowIfNullOrEmpty(secWebSocketKey); + // SHA-1 is mandated by RFC 6455 §1.3 for this handshake field only. + return Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(secWebSocketKey + AcceptGuid))); // NOSONAR + } +} diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/Rfc8441H11ToH2TunnelTests.cs b/tests/Titanium.Web.Proxy.IntegrationTests/Rfc8441H11ToH2TunnelTests.cs new file mode 100644 index 00000000..81329d12 --- /dev/null +++ b/tests/Titanium.Web.Proxy.IntegrationTests/Rfc8441H11ToH2TunnelTests.cs @@ -0,0 +1,629 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.IntegrationTests.Helpers; +using Titanium.Web.Proxy.IntegrationTests.Setup; +using Titanium.Web.Proxy.Models; + +namespace Titanium.Web.Proxy.IntegrationTests; + +/// +/// Integration tests for HTTP/1.1 WebSocket Upgrade → HTTP/2 origin (RFC 8441) via +/// Http11ToHttp2BridgeHandler, including the HTTP/1.1 origin fallback when the h2 origin +/// does not advertise SETTINGS_ENABLE_CONNECT_PROTOCOL. +/// +[DoNotParallelize] +[TestClass] +public class Rfc8441H11ToH2TunnelTests +{ + private static TestServer sharedServer = null!; + private static readonly Encoding Ascii = Encoding.ASCII; + private const string SampleWsKey = "dGhlIHNhbXBsZSBub25jZQ=="; + + [ClassInitialize] + public static void ClassSetup(TestContext _) + { + sharedServer = new TestServer(TestCertificateAuthority.ServerCertificate, requireMutualTls: false); + } + + [ClassCleanup] + public static void ClassCleanup() + { + sharedServer?.Dispose(); + } + + [TestMethod] + [Timeout(30_000)] + public async Task H11Upgrade_Rfc8441Disabled_StillReturns501() + { + using var rawOrigin = new Http2RawOriginServer(TestCertificateAuthority.ServerCertificate); + rawOrigin.HandleConnection(async originConn => + { + await originConn.SendInitialSettingsWithConnectProtocolAsync(); + // Should never receive the CONNECT when the feature is disabled. + try + { + while (true) _ = await originConn.ReadFrameAsync(); + } + catch + { + // connection closed by proxy + } + }); + + using var testSuite = new TestSuite(sharedServer); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableRfc8441 = false; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http2; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + using var tunnel = await Http2RawClient.ConnectTunnelWithAlpnAsync( + proxy.ProxyEndPoints[0].Port, "localhost", rawOrigin.Port, + new List { SslApplicationProtocol.Http11 }); + + var request = BuildHttp11WebSocketUpgradeRequest($"localhost:{rawOrigin.Port}"); + await tunnel.SslStream.WriteAsync(request); + + var responseText = await ReadHttp11ResponseAsync(tunnel.SslStream); + Assert.IsTrue(responseText.StartsWith("HTTP/1.1 501", StringComparison.Ordinal), + $"EnableRfc8441=false must keep the historical 501; got: {FirstLine(responseText)}"); + } + + [TestMethod] + [Timeout(30_000)] + public async Task H11Upgrade_Rfc8441Enabled_H2OriginWithSetting_Returns101AndEchoes() + { + string? observedMethod = null; + string? observedProtocol = null; + string? observedSubprotocol = null; + + using var rawOrigin = new Http2RawOriginServer(TestCertificateAuthority.ServerCertificate); + rawOrigin.HandleConnection(async originConn => + { + await originConn.SendInitialSettingsWithConnectProtocolAsync(); + + var (reqStreamId, reqHeaders, endStream) = await originConn.ReadHeaderBlockAsync(); + observedMethod = reqHeaders.Single(h => h.Name == ":method").Value; + observedProtocol = reqHeaders.SingleOrDefault(h => h.Name == ":protocol").Value; + observedSubprotocol = reqHeaders.SingleOrDefault(h => h.Name == "sec-websocket-protocol").Value; + Assert.IsFalse(endStream, "Extended CONNECT HEADERS must not carry END_STREAM."); + + var resp200 = originConn.EncodeHeaders( + new[] { (":status", "200") }, + new[] { ("sec-websocket-protocol", "chat") }); + await originConn.WriteHeaderBlockAsync(reqStreamId, resp200, false); + + while (true) + { + var frame = await originConn.ReadFrameAsync(); + if (frame.Type == Http2FrameType.Data && frame.StreamId == reqStreamId) + { + if (frame.Payload.Length > 0) + await originConn.WriteFrameAsync(Http2FrameType.Data, reqStreamId, 0, frame.Payload); + if ((frame.Flags & Http2FrameFlag.EndStream) != 0) + { + await originConn.WriteFrameAsync(Http2FrameType.Data, reqStreamId, + Http2FrameFlag.EndStream, Array.Empty()); + break; + } + } + else if (frame.Type is Http2FrameType.RstStream or Http2FrameType.GoAway) + { + break; + } + } + }); + + using var testSuite = new TestSuite(sharedServer); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableRfc8441 = true; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http2; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + using var tunnel = await Http2RawClient.ConnectTunnelWithAlpnAsync( + proxy.ProxyEndPoints[0].Port, "localhost", rawOrigin.Port, + new List { SslApplicationProtocol.Http11 }); + + var request = BuildHttp11WebSocketUpgradeRequest($"localhost:{rawOrigin.Port}", "chat"); + await tunnel.SslStream.WriteAsync(request); + + var (statusLine, headers, remainder) = await ReadHttp11HeadersAsync(tunnel.SslStream); + Assert.IsTrue(statusLine.StartsWith("HTTP/1.1 101", StringComparison.Ordinal), + $"Expected 101 Switching Protocols; got: {statusLine}"); + + var expectedAccept = Convert.ToBase64String(SHA1.HashData( + Ascii.GetBytes(SampleWsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); + Assert.AreEqual(expectedAccept, GetHeader(headers, "Sec-WebSocket-Accept"), + "Proxy must synthesize Sec-WebSocket-Accept from the client's Sec-WebSocket-Key."); + Assert.AreEqual("chat", GetHeader(headers, "Sec-WebSocket-Protocol"), + "Negotiated subprotocol from the h2 origin must be relayed on the 101."); + + Assert.AreEqual("CONNECT", observedMethod, "Origin must receive extended CONNECT."); + Assert.AreEqual("websocket", observedProtocol, "Origin must receive :protocol=websocket."); + Assert.AreEqual("chat", observedSubprotocol, "Subprotocol must be forwarded to the h2 origin."); + + var payload = Ascii.GetBytes("hello-h11-to-h2"); + var wsFrame = BuildMaskedTextFrame(payload); + await tunnel.SslStream.WriteAsync(wsFrame); + + var echoed = await ReadExactWithPrefixAsync(tunnel.SslStream, remainder, wsFrame.Length, + TimeSpan.FromSeconds(10)); + CollectionAssert.AreEqual(wsFrame, echoed, + "WebSocket frame bytes must be relayed byte-for-byte through the h2 DATA tunnel."); + } + + [TestMethod] + [Timeout(30_000)] + public async Task H11Upgrade_MissingSecWebSocketKey_Returns400() + { + using var rawOrigin = new Http2RawOriginServer(TestCertificateAuthority.ServerCertificate); + rawOrigin.HandleConnection(async originConn => + { + await originConn.SendInitialSettingsWithConnectProtocolAsync(); + try + { + while (true) _ = await originConn.ReadFrameAsync(); + } + catch + { + // closed + } + }); + + using var testSuite = new TestSuite(sharedServer); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableRfc8441 = true; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http2; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + using var tunnel = await Http2RawClient.ConnectTunnelWithAlpnAsync( + proxy.ProxyEndPoints[0].Port, "localhost", rawOrigin.Port, + new List { SslApplicationProtocol.Http11 }); + + var request = Ascii.GetBytes( + "GET /ws HTTP/1.1\r\n" + + $"Host: localhost:{rawOrigin.Port}\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n"); + await tunnel.SslStream.WriteAsync(request); + + var responseText = await ReadHttp11ResponseAsync(tunnel.SslStream); + Assert.IsTrue(responseText.StartsWith("HTTP/1.1 400", StringComparison.Ordinal), + $"Missing Sec-WebSocket-Key must return 400; got: {FirstLine(responseText)}"); + } + + [TestMethod] + [Timeout(30_000)] + public async Task H11Upgrade_OriginRejectsExtendedConnect_SurfacesStatus() + { + using var rawOrigin = new Http2RawOriginServer(TestCertificateAuthority.ServerCertificate); + rawOrigin.HandleConnection(async originConn => + { + await originConn.SendInitialSettingsWithConnectProtocolAsync(); + var (reqStreamId, _, _) = await originConn.ReadHeaderBlockAsync(); + var resp403 = originConn.EncodeHeaders(new[] { (":status", "403") }, + Array.Empty<(string, string)>()); + await originConn.WriteHeaderBlockAsync(reqStreamId, resp403, true); + }); + + using var testSuite = new TestSuite(sharedServer); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableRfc8441 = true; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http2; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + using var tunnel = await Http2RawClient.ConnectTunnelWithAlpnAsync( + proxy.ProxyEndPoints[0].Port, "localhost", rawOrigin.Port, + new List { SslApplicationProtocol.Http11 }); + + await tunnel.SslStream.WriteAsync(BuildHttp11WebSocketUpgradeRequest($"localhost:{rawOrigin.Port}")); + var responseText = await ReadHttp11ResponseAsync(tunnel.SslStream); + Assert.IsTrue(responseText.StartsWith("HTTP/1.1 403", StringComparison.Ordinal), + $"Origin rejection must surface to the H1 client; got: {FirstLine(responseText)}"); + } + + [TestMethod] + [Timeout(30_000)] + public async Task H11Upgrade_BeforeResponseDenial_DoesNotOpenRelay() + { + using var rawOrigin = new Http2RawOriginServer(TestCertificateAuthority.ServerCertificate); + var dataFramesFromClient = 0; + rawOrigin.HandleConnection(async originConn => + { + await originConn.SendInitialSettingsWithConnectProtocolAsync(); + var (reqStreamId, _, _) = await originConn.ReadHeaderBlockAsync(); + var resp200 = originConn.EncodeHeaders(new[] { (":status", "200") }, + Array.Empty<(string, string)>()); + await originConn.WriteHeaderBlockAsync(reqStreamId, resp200, false); + + try + { + while (true) + { + var frame = await originConn.ReadFrameAsync(); + if (frame.Type == Http2FrameType.Data && frame.StreamId == reqStreamId && + frame.Payload.Length > 0) + Interlocked.Increment(ref dataFramesFromClient); + } + } + catch + { + // closed + } + }); + + using var testSuite = new TestSuite(sharedServer); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableRfc8441 = true; + proxy.BeforeResponse += (_, e) => + { + if (e.HttpClient.Response.StatusCode == 101) + e.GenericResponse("denied", HttpStatusCode.Forbidden); + return Task.CompletedTask; + }; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http2; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + using var tunnel = await Http2RawClient.ConnectTunnelWithAlpnAsync( + proxy.ProxyEndPoints[0].Port, "localhost", rawOrigin.Port, + new List { SslApplicationProtocol.Http11 }); + + await tunnel.SslStream.WriteAsync(BuildHttp11WebSocketUpgradeRequest($"localhost:{rawOrigin.Port}")); + var responseText = await ReadHttp11ResponseAsync(tunnel.SslStream); + Assert.IsTrue(responseText.StartsWith("HTTP/1.1 403", StringComparison.Ordinal), + $"BeforeResponse denial must replace the 101; got: {FirstLine(responseText)}"); + + // Give any mistaken relay a moment; the origin must not see application DATA. + await Task.Delay(300); + Assert.AreEqual(0, dataFramesFromClient, + "Denied upgrades must not start the WebSocket DATA relay."); + } + + [TestMethod] + [Timeout(30_000)] + public async Task H11Upgrade_Rfc8441Enabled_OriginWithoutSetting_FallsBackToHttp11() + { + using var dualOrigin = new DualAlpnWebSocketOrigin(TestCertificateAuthority.ServerCertificate); + + using var testSuite = new TestSuite(sharedServer); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableRfc8441 = true; + + var endpoint = (ExplicitProxyEndPoint)proxy.ProxyEndPoints[0]; + endpoint.BeforeTunnelConnectRequest += (_, e) => + { + e.UpstreamHttpProtocol = UpstreamHttpProtocol.Http2; + e.AllowHttpProtocolTranslation = true; + return Task.CompletedTask; + }; + + using var tunnel = await Http2RawClient.ConnectTunnelWithAlpnAsync( + proxy.ProxyEndPoints[0].Port, "localhost", dualOrigin.Port, + new List { SslApplicationProtocol.Http11 }); + + var request = BuildHttp11WebSocketUpgradeRequest($"localhost:{dualOrigin.Port}"); + await tunnel.SslStream.WriteAsync(request); + + var (statusLine, headers, _) = await ReadHttp11HeadersAsync(tunnel.SslStream); + Assert.IsTrue(statusLine.StartsWith("HTTP/1.1 101", StringComparison.Ordinal), + $"Fallback path must return 101; got: {statusLine}"); + + var expectedAccept = Convert.ToBase64String(SHA1.HashData( + Ascii.GetBytes(SampleWsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); + Assert.AreEqual(expectedAccept, GetHeader(headers, "Sec-WebSocket-Accept")); + + Assert.IsTrue(dualOrigin.Http2ConnectionsAccepted > 0, + "Bridge must still open the h2 origin connection (forced UpstreamHttpProtocol.Http2)."); + Assert.IsTrue(dualOrigin.Http11ConnectionsAccepted > 0, + "Without ENABLE_CONNECT_PROTOCOL the proxy must fall back to a dedicated HTTP/1.1 origin connection."); + + var payload = Ascii.GetBytes("fallback-echo"); + var wsFrame = BuildMaskedTextFrame(payload); + await tunnel.SslStream.WriteAsync(wsFrame); + + var echoed = await ReadExactAsync(tunnel.SslStream, wsFrame.Length, TimeSpan.FromSeconds(10)); + CollectionAssert.AreEqual(wsFrame, echoed, + "Fallback HTTP/1.1 WebSocket relay must echo the frame bytes."); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static byte[] BuildHttp11WebSocketUpgradeRequest(string host, string? subprotocol = null) + { + var sb = new StringBuilder(); + sb.Append("GET /ws HTTP/1.1\r\n"); + sb.Append($"Host: {host}\r\n"); + sb.Append("Upgrade: websocket\r\n"); + sb.Append("Connection: Upgrade\r\n"); + sb.Append($"Sec-WebSocket-Key: {SampleWsKey}\r\n"); + sb.Append("Sec-WebSocket-Version: 13\r\n"); + if (subprotocol != null) + sb.Append($"Sec-WebSocket-Protocol: {subprotocol}\r\n"); + sb.Append("\r\n"); + return Ascii.GetBytes(sb.ToString()); + } + + private static async Task ReadHttp11ResponseAsync(Stream stream) + { + var buffer = new byte[8192]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var total = 0; + while (total < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cts.Token); + if (read == 0) break; + total += read; + var text = Ascii.GetString(buffer, 0, total); + if (text.Contains("\r\n\r\n", StringComparison.Ordinal)) + return text; + } + + return Ascii.GetString(buffer, 0, total); + } + + private static async Task<(string StatusLine, Dictionary Headers, byte[] Remainder)> + ReadHttp11HeadersAsync(Stream stream) + { + var buffer = new byte[16384]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var total = 0; + while (total < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cts.Token); + if (read == 0) break; + total += read; + var text = Ascii.GetString(buffer, 0, total); + var idx = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (idx < 0) continue; + + var headerSection = text.Substring(0, idx); + var lines = headerSection.Split(new[] { "\r\n" }, StringSplitOptions.None); + var statusLine = lines[0]; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 1; i < lines.Length; i++) + { + var colon = lines[i].IndexOf(':'); + if (colon <= 0) continue; + headers[lines[i].Substring(0, colon).Trim()] = lines[i].Substring(colon + 1).Trim(); + } + + var headerBytes = idx + 4; + var remainder = total > headerBytes + ? buffer.AsSpan(headerBytes, total - headerBytes).ToArray() + : Array.Empty(); + return (statusLine, headers, remainder); + } + + throw new TimeoutException("Timed out waiting for an HTTP/1.1 response header block."); + } + + private static async Task ReadExactAsync(Stream stream, int count, TimeSpan timeout) => + await ReadExactWithPrefixAsync(stream, Array.Empty(), count, timeout); + + private static async Task ReadExactWithPrefixAsync(Stream stream, byte[] prefix, int count, + TimeSpan timeout) + { + var buffer = new byte[count]; + var offset = 0; + if (prefix.Length > 0) + { + var fromPrefix = Math.Min(prefix.Length, count); + Buffer.BlockCopy(prefix, 0, buffer, 0, fromPrefix); + offset = fromPrefix; + } + + using var cts = new CancellationTokenSource(timeout); + while (offset < count) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset), cts.Token); + if (read == 0) + throw new IOException($"Stream closed after {offset} of {count} bytes."); + offset += read; + } + + return buffer; + } + + private static string? GetHeader(Dictionary headers, string name) => + headers.TryGetValue(name, out var value) ? value : null; + + private static string FirstLine(string text) + { + var idx = text.IndexOf('\r'); + return idx < 0 ? text : text.Substring(0, idx); + } + + private static byte[] BuildMaskedTextFrame(byte[] payload) + { + var maskKey = new byte[] { 0x37, 0xfa, 0x21, 0x3d }; + var frame = new List { 0x81, (byte)(0x80 | payload.Length) }; + frame.AddRange(maskKey); + for (var i = 0; i < payload.Length; i++) + frame.Add((byte)(payload[i] ^ maskKey[i % 4])); + return frame.ToArray(); + } + + /// + /// Origin that accepts TLS with both h2 and http/1.1 ALPN: h2 without + /// ENABLE_CONNECT_PROTOCOL (so the bridge falls back), and http/1.1 that performs a minimal + /// WebSocket 101 + echo. + /// + private sealed class DualAlpnWebSocketOrigin : IDisposable + { + private readonly TcpListener listener; + private readonly X509Certificate2 certificate; + private bool disposed; + + public DualAlpnWebSocketOrigin(X509Certificate2 certificate) + { + this.certificate = certificate; + listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + _ = AcceptLoopAsync(); + } + + public int Port => ((IPEndPoint)listener.LocalEndpoint).Port; + public int Http2ConnectionsAccepted { get; private set; } + public int Http11ConnectionsAccepted { get; private set; } + + private async Task AcceptLoopAsync() + { + while (!disposed) + { + TcpClient client; + try + { + client = await listener.AcceptTcpClientAsync(); + } + catch + { + return; + } + + _ = Task.Run(async () => + { + try + { + await using var ssl = new SslStream(client.GetStream(), false); + await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + ApplicationProtocols = new List + { + SslApplicationProtocol.Http2, + SslApplicationProtocol.Http11 + }, + EnabledSslProtocols = System.Security.Authentication.SslProtocols.None + }); + + if (ssl.NegotiatedApplicationProtocol.Equals(SslApplicationProtocol.Http2)) + { + Http2ConnectionsAccepted++; + var preface = new byte[Http2Helper.ConnectionPreface.Length]; + await Http2RawFrame.ReadExactAsync(ssl, preface, 0, preface.Length); + var conn = new Http2RawFrame.Connection(ssl); + await conn.SendInitialSettingsAsync(); // no ENABLE_CONNECT_PROTOCOL + try + { + while (true) _ = await conn.ReadFrameAsync(); + } + catch + { + // closed + } + } + else + { + Http11ConnectionsAccepted++; + await HandleHttp11WebSocketAsync(ssl); + } + } + catch + { + // test side asserts + } + finally + { + client.Dispose(); + } + }); + } + } + + private static async Task HandleHttp11WebSocketAsync(Stream stream) + { + var upgradeRequest = await ReadUntilDoubleCrLfAsync(stream); + var keyLine = upgradeRequest.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries) + .Single(line => line.StartsWith("Sec-WebSocket-Key:", StringComparison.OrdinalIgnoreCase)); + var wsKey = keyLine.Substring(keyLine.IndexOf(':') + 1).Trim(); + var wsAccept = Convert.ToBase64String(SHA1.HashData( + Ascii.GetBytes(wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); + + await stream.WriteAsync(Ascii.GetBytes( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + + $"Sec-WebSocket-Accept: {wsAccept}\r\n\r\n")); + await stream.FlushAsync(); + + var buffer = new byte[4096]; + while (true) + { + var read = await stream.ReadAsync(buffer); + if (read <= 0) break; + await stream.WriteAsync(buffer.AsMemory(0, read)); + await stream.FlushAsync(); + } + } + + private static async Task ReadUntilDoubleCrLfAsync(Stream stream) + { + var accumulated = new StringBuilder(); + var buffer = new byte[1024]; + while (!accumulated.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + { + var read = await stream.ReadAsync(buffer); + if (read <= 0) break; + accumulated.Append(Ascii.GetString(buffer, 0, read)); + } + + return accumulated.ToString(); + } + + public void Dispose() + { + disposed = true; + listener.Stop(); + } + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http2EnableConnectProtocolSettingTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http2EnableConnectProtocolSettingTests.cs new file mode 100644 index 00000000..6e7411f9 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http2EnableConnectProtocolSettingTests.cs @@ -0,0 +1,31 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http2; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class Http2EnableConnectProtocolSettingTests +{ + [TestMethod] + public void Validate_AcceptsZeroAndOne() + { + Assert.IsNull(Http2OriginConnection.ValidateEnableConnectProtocolSetting(0, previouslyEnabled: false)); + Assert.IsNull(Http2OriginConnection.ValidateEnableConnectProtocolSetting(1, previouslyEnabled: false)); + Assert.IsNull(Http2OriginConnection.ValidateEnableConnectProtocolSetting(1, previouslyEnabled: true)); + } + + [TestMethod] + public void Validate_RejectsOutOfRangeValues() + { + Assert.IsNotNull(Http2OriginConnection.ValidateEnableConnectProtocolSetting(2, previouslyEnabled: false)); + Assert.IsNotNull(Http2OriginConnection.ValidateEnableConnectProtocolSetting(-1, previouslyEnabled: false)); + } + + [TestMethod] + public void Validate_RejectsForbiddenDowngrade() + { + var error = Http2OriginConnection.ValidateEnableConnectProtocolSetting(0, previouslyEnabled: true); + Assert.IsNotNull(error); + StringAssert.Contains(error, "downgraded"); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http2TunnelStreamTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http2TunnelStreamTests.cs new file mode 100644 index 00000000..23ebf60b --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http2TunnelStreamTests.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Http2; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class Http2TunnelStreamTests +{ + [TestMethod] + public async Task ReadAsync_CopiesPendingChunksAndSignalsEof() + { + var channel = Channel.CreateUnbounded(); + var disposed = 0; + using var stream = new Http2TunnelStream( + channel.Reader, + (_, _, _) => Task.CompletedTask, + (_, _) => Task.CompletedTask, + () => Interlocked.Increment(ref disposed)); + + await channel.Writer.WriteAsync(new byte[] { 1, 2, 3, 4 }); + channel.Writer.TryComplete(); + + var buffer = new byte[3]; + Assert.AreEqual(3, await stream.ReadAsync(buffer, 0, 3)); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, buffer); + + Assert.AreEqual(1, await stream.ReadAsync(buffer, 0, 3)); + Assert.AreEqual(4, buffer[0]); + + Assert.AreEqual(0, await stream.ReadAsync(buffer, 0, 3)); + } + + [TestMethod] + public async Task WriteAsync_ForwardsPayloadWithoutEndStream() + { + var channel = Channel.CreateUnbounded(); + ReadOnlyMemory seen = default; + var endStream = false; + + using var stream = new Http2TunnelStream( + channel.Reader, + (payload, es, _) => + { + seen = payload.ToArray(); + endStream = es; + return Task.CompletedTask; + }, + (_, _) => Task.CompletedTask, + () => { }); + + await stream.WriteAsync(new byte[] { 9, 8, 7 }, 0, 3); + CollectionAssert.AreEqual(new byte[] { 9, 8, 7 }, seen.ToArray()); + Assert.IsFalse(endStream); + } + + [TestMethod] + public async Task CompleteWriteAsync_SendsEmptyEndStream() + { + var channel = Channel.CreateUnbounded(); + var endStream = false; + var length = -1; + + using var stream = new Http2TunnelStream( + channel.Reader, + (payload, es, _) => + { + length = payload.Length; + endStream = es; + return Task.CompletedTask; + }, + (_, _) => Task.CompletedTask, + () => { }); + + await stream.CompleteWriteAsync(CancellationToken.None); + Assert.AreEqual(0, length); + Assert.IsTrue(endStream); + Assert.IsFalse(stream.CanWrite); + } + + [TestMethod] + public async Task WriteAsync_AfterCompleteWrite_Throws() + { + var channel = Channel.CreateUnbounded(); + using var stream = new Http2TunnelStream( + channel.Reader, + (_, _, _) => Task.CompletedTask, + (_, _) => Task.CompletedTask, + () => { }); + + await stream.CompleteWriteAsync(CancellationToken.None); + await Assert.ThrowsExceptionAsync( + () => stream.WriteAsync(new byte[] { 1 }, 0, 1)); + } + + [TestMethod] + public void SyncReadWrite_ThrowNotSupported() + { + var channel = Channel.CreateUnbounded(); + using var stream = new Http2TunnelStream( + channel.Reader, + (_, _, _) => Task.CompletedTask, + (_, _) => Task.CompletedTask, + () => { }); + + Assert.ThrowsException(() => stream.Read(new byte[1], 0, 1)); + Assert.ThrowsException(() => stream.Write(new byte[1], 0, 1)); + Assert.ThrowsException(() => stream.Seek(0, SeekOrigin.Begin)); + Assert.ThrowsException(() => stream.SetLength(1)); + Assert.ThrowsException(() => _ = stream.Length); + Assert.ThrowsException(() => _ = stream.Position); + Assert.ThrowsException(() => stream.Position = 0); + } + + [TestMethod] + public async Task Dispose_InvokesOnDisposedAndSendsEndStream() + { + var channel = Channel.CreateUnbounded(); + var endStreamSeen = false; + var disposed = 0; + var endStreamTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var stream = new Http2TunnelStream( + channel.Reader, + (_, es, _) => + { + if (es) + { + endStreamSeen = true; + endStreamTcs.TrySetResult(true); + } + + return Task.CompletedTask; + }, + (_, _) => Task.CompletedTask, + () => Interlocked.Increment(ref disposed)); + + stream.Dispose(); + await endStreamTcs.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.IsTrue(endStreamSeen); + Assert.AreEqual(1, disposed); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/WebSocketHandshakeTests.cs b/tests/Titanium.Web.Proxy.UnitTests/WebSocketHandshakeTests.cs new file mode 100644 index 00000000..96aae5ee --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/WebSocketHandshakeTests.cs @@ -0,0 +1,23 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class WebSocketHandshakeTests +{ + [TestMethod] + public void ComputeAccept_MatchesRfc6455Example() + { + // RFC 6455 §1.3 worked example. + var accept = WebSocketHandshake.ComputeAccept("dGhlIHNhbXBsZSBub25jZQ=="); + Assert.AreEqual("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", accept); + } + + [TestMethod] + public void ComputeAccept_NullOrEmpty_Throws() + { + Assert.ThrowsException(() => WebSocketHandshake.ComputeAccept(null!)); + Assert.ThrowsException(() => WebSocketHandshake.ComputeAccept("")); + } +} diff --git a/wiki/Home.md b/wiki/Home.md index a01686e0..fd72960e 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -270,8 +270,10 @@ proxyServer.EnableHttp2 = false; Header/body modification in `BeforeRequest`/`BeforeResponse`, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (`Ok`/`Respond`/`Redirect`/`GenericResponse`/`RespondStreaming`) all work over -HTTP/2 the same as over HTTP/1.x — see [Streaming Bodies](Streaming-Bodies). Not supported: HTTP/2 server -push and cleartext h2c upgrade. See [Protocol Feature Support](Protocol-Support) for the full breakdown. +HTTP/2 the same as over HTTP/1.x — see [Streaming Bodies](Streaming-Bodies). WebSocket over HTTP/2 +(RFC 8441), including HTTP/1.1 Upgrade → h2 origin on the translation bridge, is opt-in via +`EnableRfc8441`. Not supported: HTTP/2 server push and cleartext h2c upgrade. See +[Protocol Feature Support](Protocol-Support) for the full breakdown. ## HTTP/3 diff --git a/wiki/Protocol-Support.md b/wiki/Protocol-Support.md index 5c01c405..f59bdc4b 100644 --- a/wiki/Protocol-Support.md +++ b/wiki/Protocol-Support.md @@ -10,8 +10,9 @@ interim 1xx responses, and TLS body-write-hook parity for HTTP/1.x; HPACK dynami HEADERS/CONTINUATION reassembly and re-splitting, trailers, interim 1xx responses, two-hop flow control, SETTINGS/PING/GOAWAY handling, and synthetic-response API parity for HTTP/2), plus the subsequent protocol policy and safety hardening work (HTTP/2 frame/header-list bounds, bounded body streaming, RFC 8441 WebSocket -over HTTP/2 including both the h2-client-to-h1-origin bridge and the native h2↔h2 tunnel, WebSocket frame -validation, Via header injection, multipart streaming, stacked Content-Encoding parsing, and authentication retry +over HTTP/2 including the h2-client-to-h1-origin bridge, the native h2↔h2 tunnel, and the h1-client-to-h2-origin +bridge, WebSocket frame validation, Via header injection, multipart streaming, stacked Content-Encoding parsing, +and authentication retry bounds), and the HTTP/3 (QUIC) opt-in feature — including the six HTTP/3 gap-closure features: request-lifecycle timing, 1xx interim response relay, per-chunk streaming body hooks (`OnRequestBodyWrite`/`OnResponseBodyWrite`), upstream proxy chaining with TCP fallback, HTTPS/SVCB DNS discovery, and QPACK dynamic table (opt-in via @@ -76,7 +77,7 @@ please open an issue. | SETTINGS parameter validation | Yes | Unknown parameters are silently ignored per RFC 7540 §6.5; out-of-range values for known parameters (`INITIAL_WINDOW_SIZE`, `MAX_FRAME_SIZE`, `HEADER_TABLE_SIZE`) trigger a `PROTOCOL_ERROR`. | | CONTINUATION frame safety | Yes | A HEADERS frame with `END_HEADERS` clear must be followed by CONTINUATION frames on the same stream; any intervening frame triggers a `PROTOCOL_ERROR` connection error, closing the connection. | | Decoded header list size limit | Yes | HPACK-decoded header list bytes (name + value + 32-byte overhead per entry, per RFC 7541 §4.1) that exceed `MaxDecodedHeaderListBytes` (default 64 KiB) cause the stream to be reset with `RST_STREAM(ENHANCE_YOUR_CALM)` rather than forwarded. | -| RFC 8441 WebSocket over HTTP/2 (extended CONNECT) | Yes | `EnableRfc8441 = true` enables extended-CONNECT negotiation. Both tunnel paths are fully implemented: **h2-client→h1-origin bridge** – the proxy validates required pseudo-headers, opens an HTTP/1.1 origin connection, performs the WebSocket upgrade handshake, preserves negotiated subprotocol/extensions, and relays DATA frames bidirectionally through bounded per-stream buffers; **native h2↔h2 tunnel** – when the origin advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL=1`, the proxy forwards the extended CONNECT HEADERS decoded and re-encoded (preserving `:protocol`, `:authority`, `:scheme`, `:path`, and all application headers), marks the stream established on a final 2xx response, then raw-relays DATA frames for both directions without HTTP body buffering; `DataSent`/`DataReceived` events still fire with the unpadded tunnel payload. Per-leg SETTINGS negotiation is independent: the client's and origin's `ENABLE_CONNECT_PROTOCOL` preferences are never cross-forwarded; the proxy intercepts and independently decides what to advertise to each leg. Invalid `SETTINGS_ENABLE_CONNECT_PROTOCOL` values and the forbidden 1→0 downgrade are each connection-level `GOAWAY(PROTOCOL_ERROR)` errors. An origin that does not advertise the setting causes the affected stream to be reset with `REFUSED_STREAM` rather than forwarding malformed HEADERS. `Request.ExtendedConnectProtocol` exposes the `:protocol` token to `BeforeRequest` handlers; `Request.UpgradeToWebSocket` returns `true` for both RFC 8441 and HTTP/1.1 WebSocket upgrades. Calling `GetRequestBody`/`GetResponseBody` on an established extended CONNECT stream throws `InvalidOperationException` (these are unbounded duplex streams, not finite HTTP bodies). Post-establishment HEADERS/trailers on the tunnel stream are rejected with `RST_STREAM(PROTOCOL_ERROR)`. DATA combined with `END_STREAM`, independent per-direction half-closes, resets, and connection shutdown are handled without dropping payloads or leaking tunnel work. Only the `websocket` protocol token is implemented; unsupported `:protocol` values return `501 Not Implemented` after running `BeforeRequest` (allowing handlers to synthesize their own response). Extended CONNECT inherits the existing explicit-proxy `Via` header policy. | +| RFC 8441 WebSocket over HTTP/2 (extended CONNECT) | Yes | `EnableRfc8441 = true` enables extended-CONNECT negotiation. Three tunnel paths are fully implemented: **h2-client→h1-origin bridge** – the proxy validates required pseudo-headers, opens an HTTP/1.1 origin connection, performs the WebSocket upgrade handshake, preserves negotiated subprotocol/extensions, and relays DATA frames bidirectionally through bounded per-stream buffers; **native h2↔h2 tunnel** – when the origin advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL=1`, the proxy forwards the extended CONNECT HEADERS decoded and re-encoded (preserving `:protocol`, `:authority`, `:scheme`, `:path`, and all application headers), marks the stream established on a final 2xx response, then raw-relays DATA frames for both directions without HTTP body buffering; `DataSent`/`DataReceived` events still fire with the unpadded tunnel payload; **h1-client→h2-origin bridge** – on the HTTP/1.1-to-h2 translation bridge, `Upgrade: websocket` is translated into extended CONNECT (synthesizing `101` + `Sec-WebSocket-Accept` for the client) when the origin advertises the setting, otherwise the proxy falls back to a dedicated HTTP/1.1 origin connection for that WebSocket; with `EnableRfc8441=false` that bridge still returns synthetic `501 Not Implemented`. Per-leg SETTINGS negotiation is independent: the client's and origin's `ENABLE_CONNECT_PROTOCOL` preferences are never cross-forwarded; the proxy intercepts and independently decides what to advertise to each leg. Invalid `SETTINGS_ENABLE_CONNECT_PROTOCOL` values and the forbidden 1→0 downgrade are each connection-level `GOAWAY(PROTOCOL_ERROR)` errors. An h2 client whose origin does not advertise the setting gets `REFUSED_STREAM` rather than forwarding malformed HEADERS. `Request.ExtendedConnectProtocol` exposes the `:protocol` token to `BeforeRequest` handlers; `Request.UpgradeToWebSocket` returns `true` for both RFC 8441 and HTTP/1.1 WebSocket upgrades. Calling `GetRequestBody`/`GetResponseBody` on an established extended CONNECT stream throws `InvalidOperationException` (these are unbounded duplex streams, not finite HTTP bodies). Post-establishment HEADERS/trailers on the tunnel stream are rejected with `RST_STREAM(PROTOCOL_ERROR)`. DATA combined with `END_STREAM`, independent per-direction half-closes, resets, and connection shutdown are handled without dropping payloads or leaking tunnel work. Only the `websocket` protocol token is implemented; unsupported `:protocol` values return `501 Not Implemented` after running `BeforeRequest` (allowing handlers to synthesize their own response). Extended CONNECT inherits the existing explicit-proxy `Via` header policy. | ## WebSocket safety @@ -137,7 +138,7 @@ It requires the MsQuic native library and `System.Net.Quic.QuicListener.IsSuppor | `MaxWebSocketFramePayloadBytes` | 16,777,216 (16 MiB) | Maximum WebSocket frame payload size in intercepted sessions. Frames exceeding this are rejected. | | `ViaHeaderPseudonym` | `"titanium-web-proxy"` | Token appended to `Via` headers on forwarded requests and responses. Set to an empty string to disable. Loop detection rejects incoming requests whose `Via` already contains this token with `508 Loop Detected`. | | `CompatibilityMode100Continue` | `false` | Sends a synthetic `100 Continue` to the client before reading the request body when `Enable100ContinueBehaviour = false`, preventing deadlock with strict `Expect: 100-continue` clients. | -| `EnableRfc8441` | `false` | Enables WebSocket over HTTP/2 extended CONNECT negotiation (RFC 8441). When enabled, the proxy advertises `ENABLE_CONNECT_PROTOCOL=1` to h2 clients. If the origin is HTTP/2 and advertises `ENABLE_CONNECT_PROTOCOL=1`, the proxy uses the native h2↔h2 DATA relay; if the origin is HTTP/2 and does not, the stream is reset with `REFUSED_STREAM`. If the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. | +| `EnableRfc8441` | `false` | Enables WebSocket over HTTP/2 (RFC 8441). When enabled, the proxy advertises `ENABLE_CONNECT_PROTOCOL=1` to h2 clients and also bridges HTTP/1.1 `Upgrade: websocket` onto h2 origins (extended CONNECT, or HTTP/1.1 fallback when the origin lacks the setting). For h2 clients: if the origin is HTTP/2 and advertises the setting, native h2↔h2 DATA relay is used; if the origin is HTTP/2 and does not, the stream is reset with `REFUSED_STREAM`; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. | | `EnableHttp3` | `false` | Enables HTTP/3 (QUIC) support (opt-in, experimental — suppress `TWP001`). See [HTTP-3](HTTP-3) wiki page for full details. | | `EnableQpackDynamicTable` | `false` | Enables QPACK dynamic table synchronisation per RFC 9204. Requires `EnableHttp3 = true`. See [HTTP-3](HTTP-3) for details. | | `EnableHttpsSvcbDnsDiscovery` | inherits `EnableHttp3` | Enables proactive HTTP/3 capability discovery via HTTPS/SVCB DNS queries (RFC 9460). Background-only in Auto mode; first-connection H3 adoption otherwise comes from `Alt-Svc`. |