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
1 change: 1 addition & 0 deletions .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ jobs:
/o:"justcoding121"
/d:sonar.token="$env:SONAR_TOKEN"
/d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml"
/d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**"

- name: Build for SonarCloud analysis
if: env.SONAR_TOKEN != ''
Expand Down
13 changes: 11 additions & 2 deletions src/Titanium.Web.Proxy/Handlers/RequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
/// <summary>
/// Handle the request
/// </summary>
public partial class ProxyServer

Check warning on line 25 in src/Titanium.Web.Proxy/Handlers/RequestHandler.cs

View workflow job for this annotation

GitHub Actions / build

Fix this implementation of 'IDisposable' to conform to the dispose pattern.
{
/// <summary>
/// This is the core request handler method for a particular connection from client.
Expand Down Expand Up @@ -419,14 +419,13 @@
TcpServerConnection? serverConnection, SslApplicationProtocol sslApplicationProtocol,
CancellationToken cancellationToken, CancellationTokenSource cancellationTokenSource)
{
args.HttpClient.Request.Locked = true;

// do not cache server connections for WebSockets
var noCache = args.HttpClient.Request.UpgradeToWebSocket;

if (noCache) serverConnection = null;

// H1.1 client → H3 origin bridge: resolve route from cache, warming SVCB in the background.
// Body must be buffered before Locked=true — GetRequestBody throws once the request is locked.
if (!args.HttpClient.Request.UpgradeToWebSocket)
{
var reqHost = args.HttpClient.Request.RequestUri?.Host ?? string.Empty;
Expand All @@ -438,6 +437,14 @@

if (h3Route.UseH3)
{
// Buffer the client request body before leaving the H1 pipeline. Without this, the
// body remains unread on the client stream (corrupting keep-alive reuse) and
// Http3OriginBridge forwards an empty Body to the H3 origin.
if (args.HttpClient.Request.HasBody && !args.HttpClient.Request.IsBodyRead)
await args.GetRequestBody(cancellationToken);

args.HttpClient.Request.Locked = true;

await Http3.Http3OriginBridge.ForwardAsync(args, this, h3Route, logger, cancellationToken);

// Http3OriginBridge only fetches/buffers the origin response into args.HttpClient.Response -
Expand Down Expand Up @@ -512,6 +519,8 @@
}
}

args.HttpClient.Request.Locked = true;

// a connection generator task with captured parameters via closure.
var generator = () =>
TcpConnectionFactory.GetServerConnection(this,
Expand Down
6 changes: 2 additions & 4 deletions src/Titanium.Web.Proxy/Http2/Http2Helper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2239,7 +2239,7 @@

if (rr.ContentEncoding != null)
{
// Content-Encoding may list multiple stacked encodings (e.g. "gzip, br");

Check warning on line 2242 in src/Titanium.Web.Proxy/Http2/Http2Helper.cs

View workflow job for this annotation

GitHub Actions / build

Remove this commented out code.
// previously only a single encoding name was ever recognized here, so a
// stacked value matched none of the known headers and silently fell through
// CompressionNameToEnum as Unsupported.
Expand Down Expand Up @@ -2383,7 +2383,7 @@
}

/*using (var fs = new System.IO.FileStream($@"c:\temp\{connectionId}.{streamId}.dat", FileMode.Append))
{

Check warning on line 2386 in src/Titanium.Web.Proxy/Http2/Http2Helper.cs

View workflow job for this annotation

GitHub Actions / build

Remove this commented out code.
fs.Write(headerBuffer, 0, headerBuffer.Length);
fs.Write(buffer, 0, length);
}*/
Expand Down Expand Up @@ -2743,7 +2743,7 @@
await output.WriteAsync(payload, 0, 8);

// GOAWAY is often immediately followed by connection teardown (the sending relay returns
// and cancels its peer). Flush so the frame reaches the wire before the socket is closed;

Check warning on line 2746 in src/Titanium.Web.Proxy/Http2/Http2Helper.cs

View workflow job for this annotation

GitHub Actions / build

Remove this commented out code.
// otherwise clients can observe a TCP RST without ever seeing the error code.
await output.FlushAsync();
}
Expand Down Expand Up @@ -3233,13 +3233,11 @@
}
}

public Uri GetUri()

Check warning on line 3236 in src/Titanium.Web.Proxy/Http2/Http2Helper.cs

View workflow job for this annotation

GitHub Actions / build

Remove the unused private method 'GetUri'.
{
if (Authority.Length == 0)
{
// todo
Authority = HttpHeader.Encoding.GetBytes("abc.abc");
}
throw new InvalidOperationException(
"HTTP/2 request is missing the :authority pseudo-header.");

var bytes = new byte[scheme.Length + 3 + Authority.Length + Path.Length];
scheme.Span.CopyTo(bytes);
Expand Down
12 changes: 5 additions & 7 deletions src/Titanium.Web.Proxy/Http3/Http3Frame.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
using System;
using System.Net.Quic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace Titanium.Web.Proxy.Http3;

/// <summary>
/// HTTP/3 frame as read from or written to a QUIC stream.
/// HTTP/3 frame as read from or written to a stream (typically a QUIC stream).
/// Format: <c>Type (VarInt) | Length (VarInt) | Payload (Length bytes)</c> (RFC 9114 §7.1).
/// </summary>
#pragma warning disable CA1416
internal sealed class Http3Frame
{
public ulong Type { get; init; }
Expand All @@ -21,7 +20,7 @@ internal sealed class Http3Frame
/// </summary>
/// <exception cref="Http3ConnectionException">On malformed frame (e.g., huge payload).</exception>
public static async ValueTask<Http3Frame?> ReadAsync(
QuicStream stream,
Stream stream,
long maxPayloadBytes,
CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -61,7 +60,7 @@ internal sealed class Http3Frame
/// Writes a frame (type + length + payload) to <paramref name="stream" />.
/// </summary>
public static async ValueTask WriteAsync(
QuicStream stream,
Stream stream,
ulong frameType,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken)
Expand All @@ -83,9 +82,8 @@ public static async ValueTask WriteAsync(
/// Writes a zero-payload frame (used for GOAWAY and some SETTINGS without parameters).
/// </summary>
public static async ValueTask WriteAsync(
QuicStream stream,
Stream stream,
ulong frameType,
CancellationToken cancellationToken)
=> await WriteAsync(stream, frameType, ReadOnlyMemory<byte>.Empty, cancellationToken);
}
#pragma warning restore CA1416
11 changes: 9 additions & 2 deletions src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,14 @@
{
try
{
// Pass the session so ServerCertificateValidationCallback is honoured. The factory's
// default path supplies sessionArgs: null, which skips the user callback and rejects
// any chain that is not already trusted by the OS (breaking MITM-test and custom-CA
// deployments for every H3→H3 origin connect).
quicConn = await server.QuicConnectionPool.GetOrCreateAsync(
connectHost, port, upStreamEndPoint, upstreamProxy,
null /* default cert validation */,
(sender, certificate, chain, errors) =>
server.ValidateServerCertificate(sender, sessionArgs, certificate, chain, errors),
cancellationToken,
sniHost: sniHost);

Expand Down Expand Up @@ -261,7 +266,7 @@

var maxPayload = sessionArgs.MaxBufferedBodyBytes ?? server.MaxBufferedBodyBytes;
var bodyStream = new System.IO.MemoryStream();
// maxPayload above only bounds a single DATA frame (Http3Frame.ReadAsync's per-call check);

Check warning on line 269 in src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs

View workflow job for this annotation

GitHub Actions / build

Remove this commented out code.
// an origin sending many small frames could otherwise accumulate an unbounded response body
// in memory. Wrap the buffering write in a cumulative BoundedWriteStream — per the hardening
// plan, a response-side breach must close the connection rather than deliver a truncated body
Expand Down Expand Up @@ -499,7 +504,9 @@
// CompressBodyAndUpdateContentLength() assumes the opposite (decompressed Body, to be
// compressed for the wire) and would double-compress it here, corrupting the payload sent
// to the TCP-fallback origin. Forward the bytes as-is and only fix up Content-Length.
body = request.IsBodyRead ? request.Body : null;
// Use BodyAvailable: IsBodyRead is true for GET with an empty body, and Body throws
// BodyNotFoundException when HasBody is false.
body = request.BodyAvailable ? request.Body : null;
request.UpdateContentLength();
}

Expand Down
45 changes: 27 additions & 18 deletions src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,10 @@
(System.Net.IPEndPoint)connection.LocalEndPoint,
(System.Net.IPEndPoint)connection.RemoteEndPoint);

var request = new Request();
request.Method = method;
var url = BuildUrl(scheme ?? "https", authority, path ?? "/");
request.RequestUri = new Uri(url);
request.HttpVersion = HttpHeader.Version30;
request.IsHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);

foreach (var (name, value) in regularHeaders)
request.Headers.AddHeader(new HttpHeader(name, value));

// 4. Create SessionEventArgs using a null-backed HttpClientStream.
// 4. Create SessionEventArgs using a null-backed HttpClientStream, then populate
// the session's Request. SessionEventArgs always constructs its own Request; a
// discarded local Request previously left Host/URI empty so H3→origin forwarding
// failed with Invalid URI: 'http://'.
cts = new CancellationTokenSource();
linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);

Expand All @@ -119,6 +112,22 @@

sessionArgs = new SessionEventArgs(server, endPoint, nullHttpClientStream, null, cts);

var request = sessionArgs.HttpClient.Request;
request.Method = method;
// Mirror Http2Helper: keep :authority and :path separate (origin-form RequestUriString8).
// Storing an absolute URL here made transparent H3→H1 SendRequest write absolute-form
// request targets ("GET https://host/path HTTP/1.1"), which Kestrel rejects with 400.
var normalizedPath = path ?? "/";
if (!normalizedPath.StartsWith('/'))
normalizedPath = "/" + normalizedPath;

Check warning on line 122 in src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this hardcoded path-delimiter.

See more on https://sonarcloud.io/project/issues?id=justcoding121_titanium-web-proxy&issues=AZ_E7sCJETN2Y3sJujU9&open=AZ_E7sCJETN2Y3sJujU9&pullRequest=971
request.Authority = (ByteString)authority;
request.RequestUriString8 = (ByteString)normalizedPath;
request.HttpVersion = HttpHeader.Version30;
request.IsHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);

foreach (var (name, value) in regularHeaders)
request.Headers.AddHeader(new HttpHeader(name, value));

// Seed per-connection overrides from the auth event.
// CustomUpStreamProxy is the typed proxy field read by the bridge; UserData is
// intentionally left null so the public API is not polluted with internal state.
Expand Down Expand Up @@ -175,6 +184,10 @@
}
else
{
// Lock after BeforeRequest so GetResponseBody (TCP fallback) and API contracts
// agree the request has been committed to the origin pipeline.
sessionArgs.HttpClient.Request.Locked = true;

// 7. Forward to origin using the appropriate protocol bridge (H3→H3, H3→H2, or H3→H1.1).
// Pass a relay callback so that 1xx interim responses are forwarded to the client before
// the final response arrives.
Expand Down Expand Up @@ -402,8 +415,9 @@
var qpackHeaders = QpackEncoder.Encode(headers, qpackContext);
await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct);

// Send body if present.
var body = response.IsBodyRead ? response.Body : null;
// Send body if present. Ok()/Respond assign Body without setting IsBodyRead (H1 uses
// BodyAvailable); requiring IsBodyRead alone dropped every synthetic H3 response body.
var body = response.BodyAvailable || response.IsBodyRead ? response.Body : null;
if (body is { Length: > 0 })
await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct);

Expand Down Expand Up @@ -438,10 +452,5 @@
return (method, scheme, authority, path, regular);
}

private static string BuildUrl(string scheme, string authority, string path)
{
if (!path.StartsWith('/')) path = "/" + path;
return $"{scheme}://{authority}{path}";
}
}
#pragma warning restore CA1416
10 changes: 3 additions & 7 deletions src/Titanium.Web.Proxy/Http3/Http3VarInt.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System;
using System.Buffers;
using System.IO;
using System.Net.Quic;
using System.Threading;
using System.Threading.Tasks;

Expand All @@ -15,7 +13,6 @@ namespace Titanium.Web.Proxy.Http3;
/// <c>10</c> = 4 bytes (30-bit value), <c>11</c> = 8 bytes (62-bit value).
/// </para>
/// </summary>
#pragma warning disable CA1416
internal static class Http3VarInt
{
public const ulong Max1ByteValue = (1UL << 6) - 1; // 63
Expand Down Expand Up @@ -125,10 +122,10 @@ public static bool TryRead(ReadOnlySpan<byte> source, out ulong value, out int b
}

/// <summary>
/// Reads a variable-length integer from a <see cref="QuicStream" />.
/// Reads a variable-length integer from a <see cref="Stream" /> (including <see cref="QuicStream" />).
/// Returns <see langword="null" /> when the stream ends before a complete integer arrives.
/// </summary>
public static async ValueTask<ulong?> ReadAsync(QuicStream stream, CancellationToken cancellationToken)
public static async ValueTask<ulong?> ReadAsync(Stream stream, CancellationToken cancellationToken)
{
var oneByte = new byte[1];
if (!await ReadExactAsync(stream, oneByte, cancellationToken)) return null;
Expand All @@ -151,7 +148,7 @@ public static bool TryRead(ReadOnlySpan<byte> source, out ulong value, out int b
return value;
}

private static async ValueTask<bool> ReadExactAsync(QuicStream stream, Memory<byte> buffer, CancellationToken ct)
private static async ValueTask<bool> ReadExactAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
{
var offset = 0;
while (offset < buffer.Length)
Expand All @@ -163,4 +160,3 @@ private static async ValueTask<bool> ReadExactAsync(QuicStream stream, Memory<by
return true;
}
}
#pragma warning restore CA1416
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#pragma warning disable CA1416
using System;
using System.Net.Quic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

Expand All @@ -17,7 +16,7 @@ internal static class QpackDecoderStreamWriter
/// Continuously drains the decoder ack channel and writes instructions to
/// <paramref name="stream" /> until the channel is completed or <paramref name="ct" /> is cancelled.
/// </summary>
internal static async Task RunAsync(QuicStream stream, QpackContext context, CancellationToken ct)
internal static async Task RunAsync(Stream stream, QpackContext context, CancellationToken ct)
{
var reader = context.DecoderAckChannel.Reader;

Expand All @@ -33,4 +32,3 @@ internal static async Task RunAsync(QuicStream stream, QpackContext context, Can
catch (Exception) { /* stream closed — stop writing */ }
}
}
#pragma warning restore CA1416
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#pragma warning disable CA1416
using System;
using System.Net.Quic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -33,7 +32,7 @@ internal static class QpackEncoderStreamReader
/// dynamic-table insertion silently.
/// </para>
/// </summary>
internal static async Task ProcessAsync(QuicStream stream, QpackContext context, CancellationToken ct)
internal static async Task ProcessAsync(Stream stream, QpackContext context, CancellationToken ct)
{
var readBuffer = new byte[4096];
var pending = Array.Empty<byte>();
Expand Down Expand Up @@ -80,8 +79,9 @@ internal static async Task ProcessAsync(QuicStream stream, QpackContext context,
/// silent no-op (matching the pre-existing, deliberately lenient behavior here) rather than
/// torn down as a connection error, since the entry may simply have been evicted by the time
/// this reader catches up - only a truncated instruction at end-of-stream is fatal.
/// Exposed internally for unit tests that feed crafted instruction bytes without a live QUIC stream.
/// </summary>
private static bool TryParseOneInstruction(ReadOnlySpan<byte> data, QpackContext context, out int consumed)
internal static bool TryParseOneInstruction(ReadOnlySpan<byte> data, QpackContext context, out int consumed)
{
consumed = 0;
if (data.IsEmpty) return false;
Expand Down Expand Up @@ -183,4 +183,3 @@ private static bool TryReadStringLiteral(ReadOnlySpan<byte> data, out string res
return true;
}
}
#pragma warning restore CA1416
25 changes: 25 additions & 0 deletions src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Net;
using System.Net.Security;
using System.Threading;
using System.Threading.Tasks;
using Titanium.Web.Proxy.Models;

namespace Titanium.Web.Proxy.Network.Quic;

/// <summary>
/// Creates outbound QUIC connections to HTTP/3 origins. Extracted so
/// <see cref="QuicConnectionPool" /> policy (share, invalidate, warmup) can be unit-tested
/// without MsQuic.
/// </summary>
internal interface IQuicConnectionFactory
{
Task<QuicServerConnection> CreateAsync(
string connectHost,
string sniHost,
int port,
IPEndPoint? upStreamEndPoint,
IExternalProxy? upStreamProxy,
string cacheKey,
RemoteCertificateValidationCallback? remoteCertificateValidationCallback,
CancellationToken cancellationToken);

Check warning on line 24 in src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 8 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=justcoding121_titanium-web-proxy&issues=AZ_E7r_tETN2Y3sJujU8&open=AZ_E7r_tETN2Y3sJujU8&pullRequest=971
}
4 changes: 2 additions & 2 deletions src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace Titanium.Web.Proxy.Network.Quic;
/// Creates outbound <see cref="QuicServerConnection" /> objects to origin HTTP/3 servers.
/// Analogous to <see cref="Tcp.TcpConnectionFactory" /> but for QUIC.
/// </summary>
internal sealed class QuicConnectionFactory
internal sealed class QuicConnectionFactory : IQuicConnectionFactory
{
private readonly ProxyServer _proxyServer;

Expand All @@ -43,7 +43,7 @@ internal QuicConnectionFactory(ProxyServer proxyServer)
/// expose a mechanism for CONNECT tunnelling or SOCKS5 UDP ASSOCIATE; the caller must catch this
/// and fall back to a TCP-based bridge so proxy rules are honoured.
/// </exception>
internal async Task<QuicServerConnection> CreateAsync(
public async Task<QuicServerConnection> CreateAsync(
string connectHost,
string sniHost,
int port,
Expand Down
13 changes: 11 additions & 2 deletions src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,25 @@ internal sealed class QuicConnectionPool : IAsyncDisposable
private readonly ConcurrentDictionary<string, OriginEntry> _pool = new();
private readonly ConcurrentDictionary<string, byte> _warmupsInFlight = new();
private readonly ProxyServer _proxyServer;
private readonly QuicConnectionFactory _factory;
private readonly IQuicConnectionFactory _factory;
private readonly SemaphoreSlim _drainGate = new(1, 1);
private readonly CancellationTokenSource _cleanupCts = new();
private readonly Task _cleanupTask;
private volatile bool _draining;

internal QuicConnectionPool(ProxyServer proxyServer)
: this(proxyServer, new QuicConnectionFactory(proxyServer))
{
}

/// <summary>
/// Test seam: inject a fake factory so pool share/invalidate/warmup policy can be exercised
/// without MsQuic.
/// </summary>
internal QuicConnectionPool(ProxyServer proxyServer, IQuicConnectionFactory factory)
{
_proxyServer = proxyServer;
_factory = new QuicConnectionFactory(proxyServer);
_factory = factory;
// Run on the thread pool so the first sweep (which may complete synchronously if the pool
// starts empty) cannot block construction.
_cleanupTask = Task.Run(ClearIdleConnectionsAsync);
Expand Down
Loading
Loading