Thrown when BlockPrivateNetworkDestinations is enabled and a
-request's resolved destination address is loopback, private, link-local, or another
-non-globally-routable address - the outbound destination policy hook described in the
-hardening plan's "PublicFacing" posture, protecting a proxy that accepts requests from
-untrusted clients against SSRF into the host's own private network.
+
Thrown when an outbound destination policy hook rejects a resolved destination address:
+BlockPrivateNetworkDestinations (private, link-local,
+loopback, and other non-globally-routable ranges) or
+BlockLoopbackDestinations (loopback only).
Thrown when BlockPrivateNetworkDestinations is enabled and a
-request's resolved destination address is loopback, private, link-local, or another
-non-globally-routable address - the outbound destination policy hook described in the
-hardening plan's "PublicFacing" posture, protecting a proxy that accepts requests from
-untrusted clients against SSRF into the host's own private network.
+
Thrown when an outbound destination policy hook rejects a resolved destination address:
+BlockPrivateNetworkDestinations (private, link-local,
+loopback, and other non-globally-routable ranges) or
+BlockLoopbackDestinations (loopback only).
Outbound destination policy hook: when true, every resolved destination
+IP address is checked for loopback (127.0.0.0/8, ::1, including IPv4-mapped
+forms such as ::ffff:127.0.0.1) before connecting, and the connection attempt is
+rejected with an OutboundDestinationBlockedException if it matches.
+Private (RFC 1918), link-local, and cloud metadata addresses (including
+169.254.169.254) remain reachable under this flag alone.
+
+ Off by default. Use when the proxy must allow intranet destinations (for example
+ 10.*) but must still stop clients from pivoting to the proxy host's own loopback.
+ BlockPrivateNetworkDestinations already covers loopback as part of its
+ broader set; when that flag is enabled, this property is not consulted for the reject.
+ Assigning Profile does not read or clear this property.
+
+
+ Enforced on TCP outbound connects only (the same call sites as
+ BlockPrivateNetworkDestinations). Origin HTTP/3 (QUIC) dials a hostname
+ and is not covered. An explicitly configured upstream proxy address is always exempt.
+ Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled.
+
+
+
+
Declaration
+
+
public bool BlockLoopbackDestinations { get; set; }
diff --git a/docs/index.json b/docs/index.json
index 16c515417..421297d91 100644
--- a/docs/index.json
+++ b/docs/index.json
@@ -167,7 +167,7 @@
"api/Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException.html": {
"href": "api/Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException.html",
"title": "Class OutboundDestinationBlockedException | Titanium Web Proxy",
- "summary": "Class OutboundDestinationBlockedException Thrown when BlockPrivateNetworkDestinations is enabled and a request's resolved destination address is loopback, private, link-local, or another non-globally-routable address - the outbound destination policy hook described in the hardening plan's \"PublicFacing\" posture, protecting a proxy that accepts requests from untrusted clients against SSRF into the host's own private network. Inheritance object Exception ProxyException OutboundDestinationBlockedException Implements ISerializable Inherited Members Exception.GetBaseException() Exception.GetType() Exception.ToString() Exception.Data Exception.HelpLink Exception.HResult Exception.InnerException Exception.Message Exception.Source Exception.StackTrace Exception.TargetSite object.Equals(object) object.Equals(object, object) object.GetHashCode() object.ReferenceEquals(object, object) Namespace: Titanium.Web.Proxy.Exceptions Assembly: Titanium.Web.Proxy.dll Syntax public sealed class OutboundDestinationBlockedException : ProxyException, ISerializable Properties | Edit this page View Source BlockedAddress The specific resolved IP address (as a string) that triggered the block. Declaration public string BlockedAddress { get; } Property Value Type Description string | Edit this page View Source Hostname The hostname that was being connected to when the block occurred. Declaration public string Hostname { get; } Property Value Type Description string Implements ISerializable"
+ "summary": "Class OutboundDestinationBlockedException Thrown when an outbound destination policy hook rejects a resolved destination address: BlockPrivateNetworkDestinations (private, link-local, loopback, and other non-globally-routable ranges) or BlockLoopbackDestinations (loopback only). Inheritance object Exception ProxyException OutboundDestinationBlockedException Implements ISerializable Inherited Members Exception.GetBaseException() Exception.GetType() Exception.ToString() Exception.Data Exception.HelpLink Exception.HResult Exception.InnerException Exception.Message Exception.Source Exception.StackTrace Exception.TargetSite object.Equals(object) object.Equals(object, object) object.GetHashCode() object.ReferenceEquals(object, object) Namespace: Titanium.Web.Proxy.Exceptions Assembly: Titanium.Web.Proxy.dll Syntax public sealed class OutboundDestinationBlockedException : ProxyException, ISerializable Properties | Edit this page View Source BlockedAddress The specific resolved IP address (as a string) that triggered the block. Declaration public string BlockedAddress { get; } Property Value Type Description string | Edit this page View Source Hostname The hostname that was being connected to when the block occurred. Declaration public string Hostname { get; } Property Value Type Description string Implements ISerializable"
},
"api/Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException.html": {
"href": "api/Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException.html",
@@ -207,7 +207,7 @@
"api/Titanium.Web.Proxy.Exceptions.html": {
"href": "api/Titanium.Web.Proxy.Exceptions.html",
"title": "Namespace Titanium.Web.Proxy.Exceptions | Titanium Web Proxy",
- "summary": "Namespace Titanium.Web.Proxy.Exceptions Classes BodyNotFoundException An exception thrown when body is unexpectedly empty. OutboundDestinationBlockedException Thrown when BlockPrivateNetworkDestinations is enabled and a request's resolved destination address is loopback, private, link-local, or another non-globally-routable address - the outbound destination policy hook described in the hardening plan's \"PublicFacing\" posture, protecting a proxy that accepts requests from untrusted clients against SSRF into the host's own private network. ProxyAuthorizationException Proxy authorization exception. ProxyConnectException Proxy Connection exception. ProxyException Base class exception associated with this proxy server. ProxyHttpException Proxy HTTP exception. ProxyTimeoutException Thrown when a configured proxy timeout elapses. Surfaced through Titanium.Web.Proxy.Logging.ProxyDiagnostics so callers can distinguish connect, response-header, idle, and total request deadlines. UpstreamProxyConnectException Thrown when an HTTP upstream proxy rejects a CONNECT tunnel (or otherwise fails to establish one) with a non-success response. Carries the upstream status, headers, and a bounded body snapshot for diagnostics. Relaying that response to the client is only safe before the client-facing CONNECT 200 has been committed — enable EstablishServerConnectionBeforeResponse and handle BeforeTunnelConnectFailure (issue #768). Enums ProxyTimeoutKind Identifies which configured proxy timeout elapsed."
+ "summary": "Namespace Titanium.Web.Proxy.Exceptions Classes BodyNotFoundException An exception thrown when body is unexpectedly empty. OutboundDestinationBlockedException Thrown when an outbound destination policy hook rejects a resolved destination address: BlockPrivateNetworkDestinations (private, link-local, loopback, and other non-globally-routable ranges) or BlockLoopbackDestinations (loopback only). ProxyAuthorizationException Proxy authorization exception. ProxyConnectException Proxy Connection exception. ProxyException Base class exception associated with this proxy server. ProxyHttpException Proxy HTTP exception. ProxyTimeoutException Thrown when a configured proxy timeout elapses. Surfaced through Titanium.Web.Proxy.Logging.ProxyDiagnostics so callers can distinguish connect, response-header, idle, and total request deadlines. UpstreamProxyConnectException Thrown when an HTTP upstream proxy rejects a CONNECT tunnel (or otherwise fails to establish one) with a non-success response. Carries the upstream status, headers, and a bounded body snapshot for diagnostics. Relaying that response to the client is only safe before the client-facing CONNECT 200 has been committed — enable EstablishServerConnectionBeforeResponse and handle BeforeTunnelConnectFailure (issue #768). Enums ProxyTimeoutKind Identifies which configured proxy timeout elapsed."
},
"api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html": {
"href": "api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html",
@@ -552,7 +552,7 @@
"api/Titanium.Web.Proxy.ProxyServer.html": {
"href": "api/Titanium.Web.Proxy.ProxyServer.html",
"title": "Class ProxyServer | Titanium Web Proxy",
- "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Fields | Edit this page View Source DefaultViaHeaderPseudonym Default Via header pseudonym (RFC 9110 §7.6.3). Used by ViaHeaderPseudonym and by Inspector when Add Via header is enabled. Declaration public const string DefaultViaHeaderPseudonym = \"titanium-web-proxy\" Field Value Type Description string Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DecryptFailureBypassMaxEntries Maximum learned hosts retained (approximate LRU eviction). Default 256. Declaration public int DecryptFailureBypassMaxEntries { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassThreshold Origin TLS failure strikes required before a host is bypassed on later CONNECTs. Same-CONNECT opaque fallback after an awaited H2 probe failure marks bypass immediately. Default 2. Declaration public int DecryptFailureBypassThreshold { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassTtl How long a learned decrypt-bypass entry remains valid. Default 30 minutes. Declaration public TimeSpan DecryptFailureBypassTtl { get; set; } Property Value Type Description TimeSpan | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableDecryptFailureBypass When true, the proxy learns hosts whose origin TLS handshake fails under MITM (non-ALPN AuthenticationException, typically bot / TLS-fingerprint rejection) and tunnels subsequent CONNECTs without decrypt. Default false so library and RPS baselines are unchanged. Inspector enables this by default. Success-path cost when on is one dictionary lookup per CONNECT. Declaration public bool EnableDecryptFailureBypass { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability selects outbound HTTP/3 on the next CONNECT or new HTTP/1.1 request. Background QUIC warm-up starts when the cache is filled so that handshake is often already done. An already-open H2↔H2 MITM session is not upgraded mid-connection. Forced Http3 fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with no inbound HTTP/3 endpoint is fine when an explicit/SOCKS/transparent TCP endpoint is present (origin-side QUIC only). A warning is emitted only when EnableHttp3 is set with no client-facing endpoints at all. Default: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, 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 false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IgnoreServerCertificateErrors When true, origin TLS certificates that fail OS chain validation are still accepted (MITM of loopback/self-signed/private CAs). Inspector's \"Ignore server certificate errors\" maps here. Default false. A subscribed ServerCertificateValidationCallback still wins. Declaration public bool IgnoreServerCertificateErrors { get; set; } Property Value Type Description bool | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to Balanced's modes (resource families enforced, Http2RelayValidation disabled so compressed H2 relay stays the verbatim-HPACK fast path). AllEnforce additionally enforces relay validation and is what PublicFacing applies. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to DefaultViaHeaderPseudonym. Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source ClearDecryptFailureBypass() Clears all learned decrypt-bypass entries. Declaration public void ClearDecryptFailureBypass() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source ForceDecryptFailureBypass(string) Marks host as actively bypassed (same as a forced learn after origin TLS failure). Raises DecryptFailureBypassChanged when bypass newly becomes active. Declaration public bool ForceDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source GetDecryptFailureBypassEntries() Snapshot of current learned decrypt-bypass entries (may include non-active strikes). Declaration public IReadOnlyList GetDecryptFailureBypassEntries() Returns Type Description IReadOnlyList | Edit this page View Source RemoveDecryptFailureBypass(string) Removes one host from the learned decrypt-bypass cache. Declaration public bool RemoveDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source ShouldBypassDecryptForLearnedHost(string?) When EnableDecryptFailureBypass is on and host is actively bypassed, returns true (decrypt should be skipped). Declaration public bool ShouldBypassDecryptForLearnedHost(string? host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryDisableAllSystemProxies() Clear all OS proxy settings without throwing. Declaration public SystemProxyChangeResult TryDisableAllSystemProxies() Returns Type Description SystemProxyChangeResult | Edit this page View Source TryDisableSystemProxy(ProxyProtocolType) Clear OS proxy for the given protocols without throwing. Declaration public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType Returns Type Description SystemProxyChangeResult | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool | Edit this page View Source TryRestoreOriginalProxySettings() Restore OS proxy without throwing. Declaration public SystemProxyChangeResult TryRestoreOriginalProxySettings() Returns Type Description SystemProxyChangeResult | Edit this page View Source TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI can show a status message instead of crashing. Declaration public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null) Parameters Type Name Description ExplicitProxyEndPoint endPoint ProxyProtocolType protocolType SystemProxySettings settings Returns Type Description SystemProxyChangeResult Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source DecryptFailureBypassChanged Raised when a host becomes actively bypassed (threshold reached or same-CONNECT mark). Handlers must not block; Inspector marshals to the UI thread. Declaration public event EventHandler? DecryptFailureBypassChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable"
+ "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Fields | Edit this page View Source DefaultViaHeaderPseudonym Default Via header pseudonym (RFC 9110 §7.6.3). Used by ViaHeaderPseudonym and by Inspector when Add Via header is enabled. Declaration public const string DefaultViaHeaderPseudonym = \"titanium-web-proxy\" Field Value Type Description string Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockLoopbackDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked for loopback (127.0.0.0/8, ::1, including IPv4-mapped forms such as ::ffff:127.0.0.1) before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Private (RFC 1918), link-local, and cloud metadata addresses (including 169.254.169.254) remain reachable under this flag alone. Off by default. Use when the proxy must allow intranet destinations (for example 10.*) but must still stop clients from pivoting to the proxy host's own loopback. BlockPrivateNetworkDestinations already covers loopback as part of its broader set; when that flag is enabled, this property is not consulted for the reject. Assigning Profile does not read or clear this property. Enforced on TCP outbound connects only (the same call sites as BlockPrivateNetworkDestinations). Origin HTTP/3 (QUIC) dials a hostname and is not covered. An explicitly configured upstream proxy address is always exempt. Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled. Declaration public bool BlockLoopbackDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DecryptFailureBypassMaxEntries Maximum learned hosts retained (approximate LRU eviction). Default 256. Declaration public int DecryptFailureBypassMaxEntries { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassThreshold Origin TLS failure strikes required before a host is bypassed on later CONNECTs. Same-CONNECT opaque fallback after an awaited H2 probe failure marks bypass immediately. Default 2. Declaration public int DecryptFailureBypassThreshold { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassTtl How long a learned decrypt-bypass entry remains valid. Default 30 minutes. Declaration public TimeSpan DecryptFailureBypassTtl { get; set; } Property Value Type Description TimeSpan | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableDecryptFailureBypass When true, the proxy learns hosts whose origin TLS handshake fails under MITM (non-ALPN AuthenticationException, typically bot / TLS-fingerprint rejection) and tunnels subsequent CONNECTs without decrypt. Default false so library and RPS baselines are unchanged. Inspector enables this by default. Success-path cost when on is one dictionary lookup per CONNECT. Declaration public bool EnableDecryptFailureBypass { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability selects outbound HTTP/3 on the next CONNECT or new HTTP/1.1 request. Background QUIC warm-up starts when the cache is filled so that handshake is often already done. An already-open H2↔H2 MITM session is not upgraded mid-connection. Forced Http3 fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with no inbound HTTP/3 endpoint is fine when an explicit/SOCKS/transparent TCP endpoint is present (origin-side QUIC only). A warning is emitted only when EnableHttp3 is set with no client-facing endpoints at all. Default: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, 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 false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IgnoreServerCertificateErrors When true, origin TLS certificates that fail OS chain validation are still accepted (MITM of loopback/self-signed/private CAs). Inspector's \"Ignore server certificate errors\" maps here. Default false. A subscribed ServerCertificateValidationCallback still wins. Declaration public bool IgnoreServerCertificateErrors { get; set; } Property Value Type Description bool | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to Balanced's modes (resource families enforced, Http2RelayValidation disabled so compressed H2 relay stays the verbatim-HPACK fast path). AllEnforce additionally enforces relay validation and is what PublicFacing applies. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to DefaultViaHeaderPseudonym. Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source ClearDecryptFailureBypass() Clears all learned decrypt-bypass entries. Declaration public void ClearDecryptFailureBypass() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source ForceDecryptFailureBypass(string) Marks host as actively bypassed (same as a forced learn after origin TLS failure). Raises DecryptFailureBypassChanged when bypass newly becomes active. Declaration public bool ForceDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source GetDecryptFailureBypassEntries() Snapshot of current learned decrypt-bypass entries (may include non-active strikes). Declaration public IReadOnlyList GetDecryptFailureBypassEntries() Returns Type Description IReadOnlyList | Edit this page View Source RemoveDecryptFailureBypass(string) Removes one host from the learned decrypt-bypass cache. Declaration public bool RemoveDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source ShouldBypassDecryptForLearnedHost(string?) When EnableDecryptFailureBypass is on and host is actively bypassed, returns true (decrypt should be skipped). Declaration public bool ShouldBypassDecryptForLearnedHost(string? host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryDisableAllSystemProxies() Clear all OS proxy settings without throwing. Declaration public SystemProxyChangeResult TryDisableAllSystemProxies() Returns Type Description SystemProxyChangeResult | Edit this page View Source TryDisableSystemProxy(ProxyProtocolType) Clear OS proxy for the given protocols without throwing. Declaration public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType Returns Type Description SystemProxyChangeResult | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool | Edit this page View Source TryRestoreOriginalProxySettings() Restore OS proxy without throwing. Declaration public SystemProxyChangeResult TryRestoreOriginalProxySettings() Returns Type Description SystemProxyChangeResult | Edit this page View Source TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI can show a status message instead of crashing. Declaration public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null) Parameters Type Name Description ExplicitProxyEndPoint endPoint ProxyProtocolType protocolType SystemProxySettings settings Returns Type Description SystemProxyChangeResult Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source DecryptFailureBypassChanged Raised when a host becomes actively bypassed (threshold reached or same-CONNECT mark). Handlers must not block; Inspector marshals to the UI thread. Declaration public event EventHandler? DecryptFailureBypassChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable"
},
"api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html": {
"href": "api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html",
@@ -602,7 +602,7 @@
"api/Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.html": {
"href": "api/Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.html",
"title": "Interface IHttpStreamReader | Titanium Web Proxy",
- "summary": "Interface IHttpStreamReader Inherited Members ILineStream.DataAvailable ILineStream.FillBufferAsync(CancellationToken) ILineStream.ReadByteFromBuffer() ILineStream.ReadLineAsync(CancellationToken) Namespace: Titanium.Web.Proxy.StreamExtended.Network Assembly: Titanium.Web.Proxy.dll Syntax public interface IHttpStreamReader : ILineStream Methods | Edit this page View Source CopyBodyAsync(IHttpStreamWriter, bool, long, bool, SessionEventArgs, CancellationToken) Declaration Task CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength, bool isRequest, SessionEventArgs args, CancellationToken cancellationToken) Parameters Type Name Description IHttpStreamWriter writer bool isChunked long contentLength bool isRequest SessionEventArgs args CancellationToken cancellationToken Returns Type Description Task | Edit this page View Source Read(byte[], int, int) Declaration int Read(byte[] buffer, int offset, int count) Parameters Type Name Description byte[] buffer int offset int count Returns Type Description int | Edit this page View Source ReadAsync(byte[], int, int, CancellationToken) Declaration Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) Parameters Type Name Description byte[] buffer int offset int count CancellationToken cancellationToken Returns Type Description Task"
+ "summary": "Interface IHttpStreamReader Inherited Members ILineStream.DataAvailable ILineStream.FillBufferAsync(CancellationToken) ILineStream.ReadByteFromBuffer() ILineStream.ReadLineAsync(CancellationToken) Namespace: Titanium.Web.Proxy.StreamExtended.Network Assembly: Titanium.Web.Proxy.dll Syntax public interface IHttpStreamReader : ILineStream Methods | Edit this page View Source CopyBodyAsync(IHttpStreamWriter, bool, long, bool, SessionEventArgs, CancellationToken) Declaration ValueTask CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength, bool isRequest, SessionEventArgs args, CancellationToken cancellationToken) Parameters Type Name Description IHttpStreamWriter writer bool isChunked long contentLength bool isRequest SessionEventArgs args CancellationToken cancellationToken Returns Type Description ValueTask | Edit this page View Source Read(byte[], int, int) Declaration int Read(byte[] buffer, int offset, int count) Parameters Type Name Description byte[] buffer int offset int count Returns Type Description int | Edit this page View Source ReadAsync(byte[], int, int, CancellationToken) Declaration Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) Parameters Type Name Description byte[] buffer int offset int count CancellationToken cancellationToken Returns Type Description Task"
},
"api/Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter.html": {
"href": "api/Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter.html",
diff --git a/docs/xrefmap.yml b/docs/xrefmap.yml
index 49e4d677b..0e0beb060 100644
--- a/docs/xrefmap.yml
+++ b/docs/xrefmap.yml
@@ -9001,6 +9001,19 @@ references:
commentId: E:Titanium.Web.Proxy.ProxyServer.BeforeUpStreamConnectRequest
fullName: Titanium.Web.Proxy.ProxyServer.BeforeUpStreamConnectRequest
nameWithType: ProxyServer.BeforeUpStreamConnectRequest
+- uid: Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations
+ name: BlockLoopbackDestinations
+ href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_BlockLoopbackDestinations
+ commentId: P:Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations
+ fullName: Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations
+ nameWithType: ProxyServer.BlockLoopbackDestinations
+- uid: Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations*
+ name: BlockLoopbackDestinations
+ href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_BlockLoopbackDestinations_
+ commentId: Overload:Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations
+ isSpec: "True"
+ fullName: Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations
+ nameWithType: ProxyServer.BlockLoopbackDestinations
- uid: Titanium.Web.Proxy.ProxyServer.BlockPrivateNetworkDestinations
name: BlockPrivateNetworkDestinations
href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_BlockPrivateNetworkDestinations
diff --git a/src/Titanium.Cli/Config/ServerConfigApplier.cs b/src/Titanium.Cli/Config/ServerConfigApplier.cs
index be23014d9..e3102bff5 100644
--- a/src/Titanium.Cli/Config/ServerConfigApplier.cs
+++ b/src/Titanium.Cli/Config/ServerConfigApplier.cs
@@ -138,6 +138,7 @@ private static void ApplyProtocolFlags(ProxyServer proxy, ServerConfig server)
}
ApplyBool(server.BlockPrivateNetworkDestinations, v => proxy.BlockPrivateNetworkDestinations = v);
+ ApplyBool(server.BlockLoopbackDestinations, v => proxy.BlockLoopbackDestinations = v);
if (!string.IsNullOrWhiteSpace(server.CheckCertificateRevocation) &&
Enum.TryParse(server.CheckCertificateRevocation, ignoreCase: true, out var revocation))
diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj
index c85f18d96..9e0545faa 100644
--- a/src/Titanium.Cli/Titanium.Cli.csproj
+++ b/src/Titanium.Cli/Titanium.Cli.csproj
@@ -7,7 +7,7 @@
latestenablefalse
- 7.0.12
+ 7.0.13Jehonathan ThomasTitanium Web Proxy CLI (titanium / twp).MIT
diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj
index bff65d868..ab852a744 100644
--- a/src/Titanium.Inspector/Titanium.Inspector.csproj
+++ b/src/Titanium.Inspector/Titanium.Inspector.csproj
@@ -8,7 +8,7 @@
enabletruefalse
- 7.0.12
+ 7.0.13Jehonathan ThomasTitanium Inspector desktop traffic debugger (PolyForm Noncommercial).LICENSE
diff --git a/src/Titanium.Plus/Titanium.Plus.csproj b/src/Titanium.Plus/Titanium.Plus.csproj
index e20b10e49..a08800287 100644
--- a/src/Titanium.Plus/Titanium.Plus.csproj
+++ b/src/Titanium.Plus/Titanium.Plus.csproj
@@ -7,7 +7,7 @@
enableTrueStrongNameKey.snk
- 7.0.12
+ 7.0.13Jehonathan ThomasTitanium Web Proxy Plus advanced features plugin (PolyForm Noncommercial).LICENSE
diff --git a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj
index 49958202d..78471b884 100644
--- a/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj
+++ b/src/Titanium.Web.Proxy.Abstractions/Titanium.Web.Proxy.Abstractions.csproj
@@ -7,7 +7,7 @@
enableTrueStrongNameKey.snk
- 7.0.12
+ 7.0.13Jehonathan ThomasShared contracts for Titanium Web Proxy routing, clusters, middleware, and plugins.MIT
diff --git a/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs b/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs
index 035d6647b..d4c97e1d6 100644
--- a/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs
+++ b/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs
@@ -32,6 +32,13 @@ public sealed class ServerConfig
public bool? BlockPrivateNetworkDestinations { get; set; }
+ ///
+ /// When true, reject TCP outbound connects to loopback destinations only
+ /// (127.0.0.0/8, ::1). Private and link-local ranges stay allowed.
+ /// Omit to leave the library default ().
+ ///
+ public bool? BlockLoopbackDestinations { get; set; }
+
/// NoCheck, Online, Offline, or OnlineNoCheck.
public string? CheckCertificateRevocation { get; set; }
diff --git a/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt
index 6a8db12d4..ac6570db5 100644
--- a/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt
+++ b/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt
@@ -238,6 +238,8 @@ Titanium.Web.Proxy.Configuration.Models.AccessLogConfig.SampleRate.set -> void
Titanium.Web.Proxy.Configuration.Models.ServerConfig
Titanium.Web.Proxy.Configuration.Models.ServerConfig.AccessLog.get -> Titanium.Web.Proxy.Configuration.Models.AccessLogConfig?
Titanium.Web.Proxy.Configuration.Models.ServerConfig.AccessLog.set -> void
+Titanium.Web.Proxy.Configuration.Models.ServerConfig.BlockLoopbackDestinations.get -> bool?
+Titanium.Web.Proxy.Configuration.Models.ServerConfig.BlockLoopbackDestinations.set -> void
Titanium.Web.Proxy.Configuration.Models.ServerConfig.BlockPrivateNetworkDestinations.get -> bool?
Titanium.Web.Proxy.Configuration.Models.ServerConfig.BlockPrivateNetworkDestinations.set -> void
Titanium.Web.Proxy.Configuration.Models.ServerConfig.CertificateManager.get -> Titanium.Web.Proxy.Configuration.Models.CertificateManagerConfig?
diff --git a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj
index cfb0fe115..9e896c422 100644
--- a/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj
+++ b/src/Titanium.Web.Proxy.Configuration/Titanium.Web.Proxy.Configuration.csproj
@@ -7,7 +7,7 @@
enableTrueStrongNameKey.snk
- 7.0.12
+ 7.0.13Jehonathan ThomasYAML/JSON configuration binding for Titanium Web Proxy CLI and reverse-proxy documents.MIT
diff --git a/src/Titanium.Web.Proxy/Exceptions/OutboundDestinationBlockedException.cs b/src/Titanium.Web.Proxy/Exceptions/OutboundDestinationBlockedException.cs
index db6dae911..478e449ae 100644
--- a/src/Titanium.Web.Proxy/Exceptions/OutboundDestinationBlockedException.cs
+++ b/src/Titanium.Web.Proxy/Exceptions/OutboundDestinationBlockedException.cs
@@ -1,11 +1,10 @@
namespace Titanium.Web.Proxy.Exceptions;
///
-/// Thrown when is enabled and a
-/// request's resolved destination address is loopback, private, link-local, or another
-/// non-globally-routable address - the outbound destination policy hook described in the
-/// hardening plan's "PublicFacing" posture, protecting a proxy that accepts requests from
-/// untrusted clients against SSRF into the host's own private network.
+/// Thrown when an outbound destination policy hook rejects a resolved destination address:
+/// (private, link-local,
+/// loopback, and other non-globally-routable ranges) or
+/// (loopback only).
///
public sealed class OutboundDestinationBlockedException : ProxyException
{
@@ -18,6 +17,20 @@ internal OutboundDestinationBlockedException(string hostname, string blockedAddr
BlockedAddress = blockedAddress;
}
+ ///
+ /// Loopback-only reject for .
+ /// Distinct from the primary constructor so the message names the loopback flag.
+ ///
+ internal OutboundDestinationBlockedException(string hostname, string blockedAddress,
+ LoopbackDestinationBlockTag _)
+ : base($"Connection to '{hostname}' ({blockedAddress}) was blocked because " +
+ $"{nameof(Proxy.ProxyServer.BlockLoopbackDestinations)} is enabled and the resolved " +
+ "address is a loopback destination.")
+ {
+ Hostname = hostname;
+ BlockedAddress = blockedAddress;
+ }
+
///
/// The hostname that was being connected to when the block occurred.
///
@@ -27,4 +40,12 @@ internal OutboundDestinationBlockedException(string hostname, string blockedAddr
/// The specific resolved IP address (as a string) that triggered the block.
///
public string BlockedAddress { get; }
+
+ ///
+ /// Distinguishes the loopback-only constructor overload from the private-network constructor.
+ ///
+ internal readonly struct LoopbackDestinationBlockTag
+ {
+ public static LoopbackDestinationBlockTag Instance => default;
+ }
}
diff --git a/src/Titanium.Web.Proxy/Extensions/StreamExtensions.cs b/src/Titanium.Web.Proxy/Extensions/StreamExtensions.cs
index 9f0dbf4bc..534e53ba0 100644
--- a/src/Titanium.Web.Proxy/Extensions/StreamExtensions.cs
+++ b/src/Titanium.Web.Proxy/Extensions/StreamExtensions.cs
@@ -18,7 +18,7 @@ internal static class StreamExtensions
///
///
///
- internal static Task CopyToAsync(this Stream input, Stream output, Action onCopy,
+ internal static ValueTask CopyToAsync(this Stream input, Stream output, Action onCopy,
IBufferPool bufferPool)
{
return CopyToAsync(input, output, onCopy, bufferPool, CancellationToken.None);
@@ -32,7 +32,7 @@ internal static Task CopyToAsync(this Stream input, Stream output, Action
///
///
- internal static async Task CopyToAsync(this Stream input, Stream output, Action? onCopy,
+ internal static async ValueTask CopyToAsync(this Stream input, Stream output, Action? onCopy,
IBufferPool bufferPool, CancellationToken cancellationToken)
{
var buffer = bufferPool.GetBuffer();
@@ -53,7 +53,11 @@ internal static async Task CopyToAsync(this Stream input, Stream output, Action<
// corrupting whichever connection borrowed it next. Awaiting the read directly lets
// it observe cancellation itself and actually stop before this method reuses its
// buffer. HttpStream.FillBufferAsync uses the same direct-await pattern.
- bytesRead = await input.ReadAsync(buffer.AsMemory(), cancellationToken);
+ var readVt = input.ReadAsync(buffer.AsMemory(), cancellationToken);
+ if (readVt.IsCompletedSuccessfully)
+ bytesRead = readVt.Result;
+ else
+ bytesRead = await readVt.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -64,7 +68,9 @@ internal static async Task CopyToAsync(this Stream input, Stream output, Action<
try
{
- await output.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
+ var writeVt = output.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
+ if (!writeVt.IsCompletedSuccessfully)
+ await writeVt.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
diff --git a/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs b/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs
index 5171653ad..5d30d826c 100644
--- a/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs
+++ b/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs
@@ -181,7 +181,7 @@ internal bool CanUseH1TerminateLite(ProxyEndPoint endPoint, Request request, boo
response.Locked = true;
- // Known-CL ≤64 KiB: materialize then one client write (headers+body) — same as fast path.
+ // Known-CL ≤64 KiB: one coalesced client write without assigning Response.Body.
// Larger / chunked bodies: still stream via CopyBody with a throwaway session (hooks unused).
const int coalesceBodyLimit = 64 * 1024;
if (response.HasBody
@@ -190,28 +190,33 @@ internal bool CanUseH1TerminateLite(ProxyEndPoint endPoint, Request request, boo
&& response.ContentLength is > 0 and <= coalesceBodyLimit)
{
var length = (int)response.ContentLength;
- var body = new byte[length];
- var read = 0;
- while (read < length)
+ var (body, pooled, poolKind) = RentH1CoalesceBodyBuffer(length);
+ try
{
- var n = await connection.Stream.ReadAsync(body.AsMemory(read, length - read),
+ var read = 0;
+ while (read < length)
+ {
+ var n = await connection.Stream.ReadAsync(body.AsMemory(read, length - read),
+ cancellationToken);
+ if (n == 0)
+ break;
+ read += n;
+ }
+
+ if (read != length)
+ {
+ closeConnection = true;
+ if (response.ContentLength != read)
+ response.ContentLength = read;
+ }
+
+ await clientStream.WriteResponseWithWireBodyAsync(response, body.AsMemory(0, read),
cancellationToken);
- if (n == 0)
- break;
- read += n;
}
-
- if (read != length)
+ finally
{
- Array.Resize(ref body, read);
- closeConnection = true;
+ ReturnH1CoalesceBodyBuffer(pooled, poolKind);
}
-
- response.Body = body;
- response.BodyIsWireEncoded = true;
- response.IsBodyReceived = true;
- response.IsBodyRead = true;
- await clientStream.WriteResponseAsync(response, cancellationToken);
}
else if (response.HasBody)
{
@@ -369,46 +374,79 @@ await connection.Stream.CopyBodyAsync(response, false, clientStream, Transformat
return false;
}
- // Known-CL ≤64 KiB: materialize before BeforeResponse so handlers can read the body.
+ // Defer known-CL body consume until after BeforeResponse so GetResponseBody still works.
+ // When still unread and no AfterResponse / body-write consumers need Body, coalesce via
+ // WriteResponseWithWireBodyAsync (probe no-op MITM). Otherwise keep Body assignment.
const int coalesceBodyLimit = 64 * 1024;
- if (response.HasBody
+ var knownClCoalesce = response.HasBody
&& !response.IsChunked
&& !response.HasTrailingHeaders
- && response.ContentLength is > 0 and <= coalesceBodyLimit)
- {
- var length = (int)response.ContentLength;
- var body = new byte[length];
- var read = 0;
- while (read < length)
- {
- var n = await connection.Stream.ReadAsync(body.AsMemory(read, length - read),
- cancellationToken);
- if (n == 0)
- break;
- read += n;
- }
-
- if (read != length)
- {
- Array.Resize(ref body, read);
- closeConnection = true;
- }
-
- response.Body = body;
- response.BodyIsWireEncoded = true;
- response.IsBodyReceived = true;
- response.IsBodyRead = true;
- }
+ && response.ContentLength is > 0 and <= coalesceBodyLimit;
args.HttpClient.Response = response;
await OnBeforeResponse(args);
response = args.HttpClient.Response;
response.Locked = true;
+ var mayNeedBodyAfterWrite = AfterResponse != null || HasOnResponseBodyWriteSubscribers;
if (response.HasBody && response.IsBodyRead)
{
await clientStream.WriteResponseAsync(response, cancellationToken);
}
+ else if (knownClCoalesce && !response.IsBodyRead)
+ {
+ var length = (int)response.ContentLength;
+ var (body, pooled, poolKind) = RentH1CoalesceBodyBuffer(length);
+ try
+ {
+ var read = 0;
+ while (read < length)
+ {
+ var n = await connection.Stream.ReadAsync(body.AsMemory(read, length - read),
+ cancellationToken);
+ if (n == 0)
+ break;
+ read += n;
+ }
+
+ if (read != length)
+ {
+ closeConnection = true;
+ if (response.ContentLength != read)
+ response.ContentLength = read;
+ }
+
+ if (mayNeedBodyAfterWrite)
+ {
+ byte[] exact;
+ if (read == body.Length && poolKind == H1CoalescePoolKind.None)
+ {
+ exact = body;
+ }
+ else
+ {
+ exact = GC.AllocateUninitializedArray(read);
+ if (read > 0)
+ Buffer.BlockCopy(body, 0, exact, 0, read);
+ }
+
+ response.Body = exact;
+ response.BodyIsWireEncoded = true;
+ response.IsBodyReceived = true;
+ response.IsBodyRead = true;
+ await clientStream.WriteResponseAsync(response, cancellationToken);
+ }
+ else
+ {
+ await clientStream.WriteResponseWithWireBodyAsync(response, body.AsMemory(0, read),
+ cancellationToken);
+ }
+ }
+ finally
+ {
+ ReturnH1CoalesceBodyBuffer(pooled, poolKind);
+ }
+ }
else if (response.HasBody)
{
await clientStream.WriteResponseAsync(response, cancellationToken);
@@ -450,6 +488,48 @@ await connection.Stream.CopyBodyAsync(response, false, clientStream, Transformat
}
}
+ private enum H1CoalescePoolKind
+ {
+ None,
+ BufferPool,
+ ArrayPool
+ }
+
+ ///
+ /// Rent a coalesce buffer: exact BufferPool when possible (≥4 KiB), else ArrayPool for tiny CL
+ /// (product ~56 B GET), else a fresh array when neither pool returns an exact-length buffer.
+ ///
+ private (byte[] Body, byte[]? Pooled, H1CoalescePoolKind Kind) RentH1CoalesceBodyBuffer(int length)
+ {
+ const int poolCoalesceMin = 4 * 1024;
+ if (length >= poolCoalesceMin)
+ {
+ var rented = BufferPool.GetBuffer(length);
+ if (rented.Length == length)
+ return (rented, rented, H1CoalescePoolKind.BufferPool);
+
+ BufferPool.ReturnBuffer(rented);
+ }
+
+ var arrayPooled = System.Buffers.ArrayPool.Shared.Rent(length);
+ if (arrayPooled.Length == length || length < poolCoalesceMin)
+ return (arrayPooled, arrayPooled, H1CoalescePoolKind.ArrayPool);
+
+ System.Buffers.ArrayPool.Shared.Return(arrayPooled);
+ return (new byte[length], null, H1CoalescePoolKind.None);
+ }
+
+ private void ReturnH1CoalesceBodyBuffer(byte[]? pooled, H1CoalescePoolKind kind)
+ {
+ if (pooled == null || kind == H1CoalescePoolKind.None)
+ return;
+
+ if (kind == H1CoalescePoolKind.BufferPool)
+ BufferPool.ReturnBuffer(pooled);
+ else
+ System.Buffers.ArrayPool.Shared.Return(pooled);
+ }
+
private static bool H1TerminateClientRequestedClose(Request request)
{
var headerValue = request.Headers.GetHeaderValueOrNull(KnownHeaders.Connection);
diff --git a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs
index b69fd79c6..02285ccc7 100644
--- a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs
@@ -668,10 +668,11 @@ private async Task EstablishHttp2OriginTcpConnectionAsync(S
else if (response.StreamBodyWriter == null)
{
response.ContentLength = fastBody.Length;
- // So WriteResponseAsync can coalesce headers+body into one TLS write.
- // exchange.Body is H2 DATA wire bytes (already content-encoded when CE is set).
- response.Body = fastBody;
- response.BodyIsWireEncoded = true;
+ // Coalesce headers+body without assigning Body (H1 client write only).
+ await clientStream.WriteResponseWithWireBodyAsync(response, fastBody, cancellationToken);
+ response.IsBodyReceived = true;
+ response.IsBodySent = true;
+ return;
}
else if (response.ContentLength < 0 && !response.IsChunked)
{
@@ -681,8 +682,10 @@ private async Task EstablishHttp2OriginTcpConnectionAsync(S
await streamBody(buffered, cancellationToken);
fastBody = buffered.ToArray();
response.ContentLength = fastBody.Length;
- response.Body = fastBody;
- response.BodyIsWireEncoded = true;
+ await clientStream.WriteResponseWithWireBodyAsync(response, fastBody, cancellationToken);
+ response.IsBodyReceived = true;
+ response.IsBodySent = true;
+ return;
}
await clientStream.WriteResponseAsync(response, cancellationToken);
diff --git a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs
index 3b0d4f096..9c2322e52 100644
--- a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs
@@ -514,6 +514,7 @@ await connection.Stream.WriteBodyAsync(body ?? Array.Empty(), request.IsCh
var response = sessionArgs.HttpClient.Response;
closeConnection = !response.KeepAlive;
var restoreResponseVersionAfterEmit = false;
+ byte[]? eagerWireBody = null;
if (!response.Locked)
{
@@ -583,18 +584,35 @@ await connection.Stream.WriteBodyAsync(body ?? Array.Empty(), request.IsCh
}
response.HttpVersion = clientHttpVersion;
- response.Body = bodyBytes;
- response.BodyIsWireEncoded = true;
- response.IsBodyRead = true;
- response.ContentLength = bodyBytes.Length;
- response.HttpVersion = HttpHeader.Version11;
- if (!sessionArgs.IsFastPath
- || response.Headers.HeaderExists(KnownHeaders.TransferEncoding.String))
- response.Headers.RemoveHeader(KnownHeaders.TransferEncoding);
- response.StreamBodyWriter = null;
- LowercaseHeaderNames(response.Headers);
- if (response.HasTrailingHeaders) LowercaseHeaderNames(response.TrailingHeaders);
- response.Locked = true;
+ // Fast path: keep wire bytes local and emit via wireBody — avoid Body stamp
+ // (coalesce without dual Body materialization). Full MITM / GetResponseBody keep Body.
+ if (sessionArgs.IsFastPath)
+ {
+ response.IsBodyRead = true;
+ response.ContentLength = bodyBytes.Length;
+ response.HttpVersion = HttpHeader.Version11;
+ if (response.Headers.HeaderExists(KnownHeaders.TransferEncoding.String))
+ response.Headers.RemoveHeader(KnownHeaders.TransferEncoding);
+ response.StreamBodyWriter = null;
+ LowercaseHeaderNames(response.Headers);
+ if (response.HasTrailingHeaders) LowercaseHeaderNames(response.TrailingHeaders);
+ response.Locked = true;
+ eagerWireBody = bodyBytes;
+ }
+ else
+ {
+ response.Body = bodyBytes;
+ response.BodyIsWireEncoded = true;
+ response.IsBodyRead = true;
+ response.ContentLength = bodyBytes.Length;
+ response.HttpVersion = HttpHeader.Version11;
+ if (response.Headers.HeaderExists(KnownHeaders.TransferEncoding.String))
+ response.Headers.RemoveHeader(KnownHeaders.TransferEncoding);
+ response.StreamBodyWriter = null;
+ LowercaseHeaderNames(response.Headers);
+ if (response.HasTrailingHeaders) LowercaseHeaderNames(response.TrailingHeaders);
+ response.Locked = true;
+ }
}
// If BeforeResponse buffered via GetResponseBody, emit the in-memory body. Otherwise
// stream origin→client DATA live (frames queued on the dedicated client frame writer).
@@ -703,7 +721,7 @@ await framed.CopyFromAsync(async (dest, token) =>
}
await Http2Helper.EmitSyntheticResponseAsync(sessionArgs, streamId, connectionState, clientStream,
- cancellationToken);
+ cancellationToken, wireBody: eagerWireBody is { } bytes ? bytes.AsMemory() : null);
if (restoreResponseVersionAfterEmit)
sessionArgs.HttpClient.Response.HttpVersion = HttpHeader.Version11;
@@ -969,7 +987,8 @@ await Http2Helper.EmitSyntheticResponseAsync(sessionArgs, ctx.StreamId,
// Task A: drains the inbound DATA-frame channel and forwards payloads to the
// origin. Cancelled via relayCt once the other direction finishes.
var toOriginTask = RelayChannelToStreamAsync(
- streamState.InboundTunnelChannel!.Reader, originStreamForRelay, sessionArgs, relayCt);
+ streamState.InboundTunnelChannel!.Reader, originStreamForRelay, sessionArgs, relayCt)
+ .AsTask();
// Task B: reads the origin's raw TCP bytes and forwards them to the h2 client.
// Uses CancellationToken.None for the source read and closes the socket below to
@@ -977,7 +996,8 @@ await Http2Helper.EmitSyntheticResponseAsync(sessionArgs, ctx.StreamId,
// OperationCanceledException from HttpStream.FillBufferAsync without poisoning the
// stream, but closing the socket is a more reliable exit signal for this relay
// (avoids racing cancel against data that still needs to be echoed).
- var toClientTask = RelayStreamToClientAsync(originStreamForRelay, bodyStream, sessionArgs, ct);
+ var toClientTask = RelayStreamToClientAsync(originStreamForRelay, bodyStream, sessionArgs, ct)
+ .AsTask();
await Task.WhenAny(toOriginTask, toClientTask);
await relayCts.CancelAsync();
@@ -1065,7 +1085,7 @@ await Http2Helper.FinalizeStreamAsync(finalStreamState,
/// Reads chunks from and writes
/// each one to until the channel completes or the token fires.
///
- private static async Task RelayChannelToStreamAsync(
+ private static async ValueTask RelayChannelToStreamAsync(
ChannelReader> reader,
Stream destination,
SessionEventArgs sessionArgs,
@@ -1074,8 +1094,13 @@ private static async Task RelayChannelToStreamAsync(
await foreach (var chunk in reader.ReadAllAsync(cancellationToken))
{
if (chunk.IsEmpty) continue;
- await destination.WriteAsync(chunk, cancellationToken);
- await destination.FlushAsync(cancellationToken);
+ var writeVt = destination.WriteAsync(chunk, cancellationToken);
+ if (!writeVt.IsCompletedSuccessfully)
+ await writeVt.ConfigureAwait(false);
+ // Flush kept (measure-gated removal only — plan W2).
+ var flushVt = destination.FlushAsync(cancellationToken);
+ if (!flushVt.IsCompletedSuccessfully)
+ await flushVt.ConfigureAwait(false);
if (MemoryMarshal.TryGetArray(chunk, out var segment) && segment.Array != null)
sessionArgs.OnDataSent(segment.Array, segment.Offset, segment.Count);
}
@@ -1091,7 +1116,7 @@ private static async Task RelayChannelToStreamAsync(
/// cancellation alone can race with arriving data that still needs to be echoed.
///
///
- private static async Task RelayStreamToClientAsync(
+ private static async ValueTask RelayStreamToClientAsync(
Stream source,
Stream destination,
SessionEventArgs sessionArgs,
@@ -1103,7 +1128,10 @@ private static async Task RelayStreamToClientAsync(
int read;
try
{
- read = await source.ReadAsync(buf.AsMemory(), CancellationToken.None);
+ var readVt = source.ReadAsync(buf.AsMemory(), CancellationToken.None);
+ read = readVt.IsCompletedSuccessfully
+ ? readVt.Result
+ : await readVt.ConfigureAwait(false);
}
catch (Exception readEx)
{
@@ -1116,8 +1144,13 @@ private static async Task RelayStreamToClientAsync(
try
{
- await destination.WriteAsync(buf.AsMemory(0, read), writeCancellationToken);
- await destination.FlushAsync(writeCancellationToken);
+ var writeVt = destination.WriteAsync(buf.AsMemory(0, read), writeCancellationToken);
+ if (!writeVt.IsCompletedSuccessfully)
+ await writeVt.ConfigureAwait(false);
+ // Flush kept (measure-gated removal only — plan W2).
+ var flushVt = destination.FlushAsync(writeCancellationToken);
+ if (!flushVt.IsCompletedSuccessfully)
+ await flushVt.ConfigureAwait(false);
sessionArgs.OnDataReceived(buf, 0, read);
}
catch (Exception writeEx)
diff --git a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs
index df4adae03..2d13b7e88 100644
--- a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs
@@ -795,19 +795,37 @@ await bodyWriter.CompleteAsync(
}
h3Response.Locked = true;
- await h3ClientStream.WriteResponseAsync(h3Response, cancellationToken);
- args.IsClientResponseCommitted = true;
- if (h3Response.StreamBodyWriter != null && !h3Response.IsBodySent)
+ // IsFastPath + already-buffered fixed body: coalesce without CompressBody.
+ if (args.IsFastPath
+ && h3Response.IsBodyRead
+ && h3Response.BodyAvailable
+ && !h3Response.IsChunked
+ && !h3Response.HasTrailingHeaders
+ && h3Response.StreamBodyWriter == null)
{
- var bodyWriter = new BodyStreamWriter(h3ClientStream, h3Response.IsChunked);
- await h3Response.StreamBodyWriter(bodyWriter, cancellationToken);
- await bodyWriter.CompleteAsync(
- h3Response.HasTrailingHeaders ? h3Response.TrailingHeaders : null, cancellationToken);
- h3Response.IsBodySent = true;
+ var wire = h3Response.Body;
+ h3Response.ClearBodyReference();
+ await h3ClientStream.WriteResponseWithWireBodyAsync(h3Response, wire, cancellationToken);
+ args.IsClientResponseCommitted = true;
+ h3Response.IsBodyReceived = true;
}
+ else
+ {
+ await h3ClientStream.WriteResponseAsync(h3Response, cancellationToken);
+ args.IsClientResponseCommitted = true;
- h3Response.IsBodyReceived = true;
+ if (h3Response.StreamBodyWriter != null && !h3Response.IsBodySent)
+ {
+ var bodyWriter = new BodyStreamWriter(h3ClientStream, h3Response.IsChunked);
+ await h3Response.StreamBodyWriter(bodyWriter, cancellationToken);
+ await bodyWriter.CompleteAsync(
+ h3Response.HasTrailingHeaders ? h3Response.TrailingHeaders : null, cancellationToken);
+ h3Response.IsBodySent = true;
+ }
+
+ h3Response.IsBodyReceived = true;
+ }
}
return new RetryResult(null, null, h3Response.KeepAlive);
@@ -958,10 +976,7 @@ await args.HttpClient.SendRequest(false, args.IsTransparent,
fastResponse.ContentLength = fastResponse.Body.Length;
}
- // Known-CL bodies up to one large-copy grain: materialize then one client write
- // (headers+body). Streaming WriteResponse + CopyBody emits a tiny header-only TLS
- // record first — under userspace delay that costs an extra shim RTT vs YARP, which
- // typically forwards a larger first write (lossy H1 cool ~0.86× → target ≥0.95×).
+ // Known-CL bodies up to one large-copy grain: coalesce headers+body without assigning Body.
// Skip when the body is already buffered (e.g. HTTP/1.0 chunked reframe above) —
// another ReadAsync would pull the next keep-alive response off the pooled socket.
const int coalesceBodyLimit = 64 * 1024;
@@ -973,7 +988,7 @@ await args.HttpClient.SendRequest(false, args.IsTransparent,
{
var serverStream = args.HttpClient.Connection.Stream;
var length = (int)fastResponse.ContentLength;
- var coalescedBody = new byte[length];
+ var (coalescedBody, pooled, poolKind) = RentH1CoalesceBodyBuffer(length);
try
{
using var idleDeadline = args.Deadlines.Start(cancellationToken,
@@ -992,9 +1007,10 @@ await args.HttpClient.SendRequest(false, args.IsTransparent,
if (read != length)
{
- Array.Resize(ref coalescedBody, read);
// Short CL read: do not return this socket to the pool (desync).
args.HttpClient.CloseServerConnection = true;
+ if (fastResponse.ContentLength != read)
+ fastResponse.ContentLength = read;
}
else if (serverStream.DataAvailable)
{
@@ -1002,10 +1018,10 @@ await args.HttpClient.SendRequest(false, args.IsTransparent,
args.HttpClient.CloseServerConnection = true;
}
- fastResponse.Body = coalescedBody;
- fastResponse.BodyIsWireEncoded = true;
- fastResponse.IsBodyReceived = true;
- fastResponse.IsBodyRead = true;
+ await args.ClientStream.WriteResponseWithWireBodyAsync(fastResponse,
+ coalescedBody.AsMemory(0, read), cancellationToken);
+ args.IsClientResponseCommitted = true;
+ return;
}
catch (OperationCanceledException ex)
{
@@ -1017,10 +1033,10 @@ await args.HttpClient.SendRequest(false, args.IsTransparent,
await HandleProxyTimeoutAsync(args, ex, cancellationToken);
return;
}
-
- await args.ClientStream.WriteResponseAsync(fastResponse, cancellationToken);
- args.IsClientResponseCommitted = true;
- return;
+ finally
+ {
+ ReturnH1CoalesceBodyBuffer(pooled, poolKind);
+ }
}
await args.ClientStream.WriteResponseAsync(fastResponse, cancellationToken);
diff --git a/src/Titanium.Web.Proxy/Helpers/PrivateNetworkGuard.cs b/src/Titanium.Web.Proxy/Helpers/PrivateNetworkGuard.cs
index ebf1f0875..6553051cb 100644
--- a/src/Titanium.Web.Proxy/Helpers/PrivateNetworkGuard.cs
+++ b/src/Titanium.Web.Proxy/Helpers/PrivateNetworkGuard.cs
@@ -4,8 +4,9 @@
namespace Titanium.Web.Proxy.Helpers;
///
-/// Backing implementation for the opt-in outbound destination policy hook (see
-/// ): classifies an
+/// Backing implementation for the opt-in outbound destination policy hooks (see
+/// and
+/// ): classifies an
/// as private, link-local, loopback, or a well-known cloud metadata endpoint - the set of
/// destinations a request smuggled through the proxy (e.g. via SSRF in a proxied request, or a
/// malicious/compromised client) should not be able to reach when the proxy is deployed facing
@@ -13,6 +14,17 @@ namespace Titanium.Web.Proxy.Helpers;
///
internal static class PrivateNetworkGuard
{
+ ///
+ /// True if is loopback after unwrapping IPv4-mapped IPv6
+ /// (127.0.0.0/8 and ::1). Used by
+ /// and by so the mapped-address rule cannot diverge.
+ ///
+ public static bool IsLoopback(IPAddress address)
+ {
+ if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
+ return IPAddress.IsLoopback(address);
+ }
+
///
/// True if is loopback, a private/unique-local range, link-local
/// (which subsumes the 169.254.169.254 cloud metadata endpoint on IPv4), or otherwise not a
@@ -21,9 +33,11 @@ internal static class PrivateNetworkGuard
///
public static bool IsBlocked(IPAddress address)
{
- if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
+ // Loopback first via the shared helper (handles IPv4-mapped forms). Remaining checks unwrap
+ // once for the private/link-local/multicast ranges.
+ if (IsLoopback(address)) return true;
- if (IPAddress.IsLoopback(address)) return true;
+ if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
return address.AddressFamily switch
{
diff --git a/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs b/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs
index 6020b7b98..2b4425525 100644
--- a/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs
+++ b/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs
@@ -255,9 +255,9 @@ private static async Task SendRawTap(Stream clientStream, Stream serverStream, I
{
// Now async relay all server=>client & client=>server data
var sendRelay =
- clientStream.CopyToAsync(serverStream, onDataSend, bufferPool, cancellationTokenSource.Token);
+ clientStream.CopyToAsync(serverStream, onDataSend, bufferPool, cancellationTokenSource.Token).AsTask();
var receiveRelay =
- serverStream.CopyToAsync(clientStream, onDataReceive, bufferPool, cancellationTokenSource.Token);
+ serverStream.CopyToAsync(clientStream, onDataReceive, bufferPool, cancellationTokenSource.Token).AsTask();
await Task.WhenAny(sendRelay, receiveRelay);
await cancellationTokenSource.CancelAsync();
diff --git a/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs b/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs
index f52d662e5..a902bcba2 100644
--- a/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs
+++ b/src/Titanium.Web.Proxy/Http/RequestResponseBase.cs
@@ -223,6 +223,19 @@ internal set
}
}
+ ///
+ /// Drop the body reference without touching headers (e.g. after writing a rented buffer).
+ ///
+ internal void ClearBodyReference()
+ {
+ BodyInternal = null;
+ bodyString = null;
+ BodyIsWireEncoded = false;
+ IsBodyRead = false;
+ IsBodyReceived = false;
+ IsBodySent = false;
+ }
+
///
/// Has the request/response body?
///
diff --git a/src/Titanium.Web.Proxy/Http/Response.cs b/src/Titanium.Web.Proxy/Http/Response.cs
index 1e5d8e05a..5fd8a35cc 100644
--- a/src/Titanium.Web.Proxy/Http/Response.cs
+++ b/src/Titanium.Web.Proxy/Http/Response.cs
@@ -183,4 +183,53 @@ internal static void ParseResponseLine(string httpStatus, out Version version, o
statusDescription = string.Empty;
}
}
+
+ ///
+ /// Parse a status line from UTF-8/ASCII bytes without allocating the full line string.
+ ///
+ internal static void ParseResponseLine(ReadOnlySpan httpStatus, out Version version, out int statusCode,
+ out string statusDescription)
+ {
+ var firstSpace = httpStatus.IndexOf((byte)' ');
+ if (firstSpace == -1)
+ throw new FormatException("Invalid HTTP status line.");
+
+ version = HttpHeader.Version11;
+ if (IsHttp10(httpStatus.Slice(0, firstSpace)))
+ version = HttpHeader.Version10;
+
+ var rest = httpStatus.Slice(firstSpace + 1);
+ var secondSpace = rest.IndexOf((byte)' ');
+ if (secondSpace != -1)
+ {
+ if (!System.Buffers.Text.Utf8Parser.TryParse(rest.Slice(0, secondSpace), out statusCode, out _))
+ throw new FormatException("Invalid HTTP status line.");
+
+ var description = rest.Slice(secondSpace + 1);
+ statusDescription = description.SequenceEqual("OK"u8)
+ ? "OK"
+ : System.Text.Encoding.ASCII.GetString(description);
+ }
+ else
+ {
+ if (!System.Buffers.Text.Utf8Parser.TryParse(rest, out statusCode, out _))
+ throw new FormatException("Invalid HTTP status line.");
+
+ statusDescription = string.Empty;
+ }
+ }
+
+ private static bool IsHttp10(ReadOnlySpan httpVersion)
+ {
+ if (httpVersion.Length != 8) return false;
+ ReadOnlySpan expected = "HTTP/1.0"u8;
+ for (var i = 0; i < 8; i++)
+ {
+ var c = httpVersion[i];
+ if (c is >= (byte)'a' and <= (byte)'z') c = (byte)(c - 32);
+ if (c != expected[i]) return false;
+ }
+
+ return true;
+ }
}
\ No newline at end of file
diff --git a/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs b/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs
index e847d6a3f..8b93e0274 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs
@@ -51,6 +51,18 @@ public Http2ConnectionState(long connectionId, CancellationTokenSource cancellat
///
public Http2FlowController ServerSendFlow { get; } = new();
+ ///
+ /// Outbound DATA toward the client that is waiting for credit.
+ /// Drained when the client→server relay reads WINDOW_UPDATE.
+ ///
+ public Http2DeferredOutboundData ClientOutboundDeferred { get; } = new();
+
+ ///
+ /// Outbound DATA toward the server that is waiting for credit.
+ /// Drained when the server→client relay reads WINDOW_UPDATE.
+ ///
+ public Http2DeferredOutboundData ServerOutboundDeferred { get; } = new();
+
/// All currently open (or draining) streams, keyed by the stream id used identically on both legs.
public ConcurrentDictionary Streams { get; } = new();
@@ -375,6 +387,8 @@ public void RemoveStream(int streamId)
TryTakeStream(streamId, out _);
ClientSendFlow.RemoveStream(streamId);
ServerSendFlow.RemoveStream(streamId);
+ ClientOutboundDeferred.CancelStream(streamId);
+ ServerOutboundDeferred.CancelStream(streamId);
}
/// Returns a stream state shell to the connection pool after finalization.
diff --git a/src/Titanium.Web.Proxy/Http2/Http2DeferredOutboundData.cs b/src/Titanium.Web.Proxy/Http2/Http2DeferredOutboundData.cs
new file mode 100644
index 000000000..b239749e1
--- /dev/null
+++ b/src/Titanium.Web.Proxy/Http2/Http2DeferredOutboundData.cs
@@ -0,0 +1,291 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+
+namespace Titanium.Web.Proxy.Http2;
+
+///
+/// Queues outbound HTTP/2 DATA that could not yet take send-window credit, so the connection
+/// frame reader can keep reading other streams. Drained when the peer sends WINDOW_UPDATE.
+/// One instance per send direction (toward client / toward server).
+///
+internal sealed class Http2DeferredOutboundData
+{
+ ///
+ /// Soft hint for receive-credit throttle helpers. Compressed-relay currently always grants
+ /// credit (withheld reclaim was unsafe); hard cap below is the memory bound.
+ ///
+ internal const int MaxFramesPerStream = 4;
+
+ ///
+ /// Hard cap: RST if more DATA arrives while already deferred. Sized for a 256 KiB body under
+ /// tight connection-window pressure (peers may emit ~1 KiB DATA frames, not only 16 KiB),
+ /// plus END_STREAM / burst margin for early-response duplex and slow-consumer backpressure.
+ ///
+ internal const int HardMaxFramesPerStream = 256;
+
+ private readonly object gate = new();
+ // LinkedList so a partial drain can AddFirst the remainder without reordering later frames.
+ private readonly Dictionary> byStream = new();
+ private readonly List roundRobinOrder = new();
+ // Stream ids whose END_STREAM frame is already on the writer, including after the queue
+ // hits zero. Cleared by CancelStream. Lets a concurrent RST observe "response still in flight"
+ // under this same lock instead of a PendingCount-then-flag gap.
+ private readonly HashSet endStreamHandedOff = new();
+ private int roundRobinIndex;
+
+ internal readonly struct PendingFrame
+ {
+ public PendingFrame(byte[] rented, int offset, int payloadLength, bool endStream)
+ {
+ Rented = rented;
+ Offset = offset;
+ PayloadLength = payloadLength;
+ EndStream = endStream;
+ }
+
+ /// ArrayPool buffer holding the DATA payload (no 9-byte header).
+ public byte[] Rented { get; }
+
+ public int Offset { get; }
+
+ public int PayloadLength { get; }
+
+ public bool EndStream { get; }
+ }
+
+ /// Frames currently waiting for stream .
+ public int PendingCount(int streamId)
+ {
+ lock (gate)
+ {
+ return byStream.TryGetValue(streamId, out var q) ? q.Count : 0;
+ }
+ }
+
+ ///
+ /// True when outbound bytes for are still queued, or END_STREAM
+ /// has been handed to the writer and has not run yet.
+ ///
+ public bool HasOutboundInFlight(int streamId)
+ {
+ lock (gate)
+ {
+ if (endStreamHandedOff.Contains(streamId))
+ return true;
+ return byStream.TryGetValue(streamId, out var q) && q.Count > 0;
+ }
+ }
+
+ /// True when receive credit toward the peer that sent this DATA should pause.
+ public bool ShouldThrottleReceiveCredit(int streamId) =>
+ PendingCount(streamId) >= MaxFramesPerStream;
+
+ ///
+ /// Enqueues payload bytes that could not be reserved. Ownership of
+ /// transfers; returned false if the hard cap was hit (caller should RST and return the buffer).
+ ///
+ public bool TryEnqueue(int streamId, byte[] rented, int offset, int payloadLength, bool endStream)
+ {
+ lock (gate)
+ {
+ if (!byStream.TryGetValue(streamId, out var q))
+ {
+ q = new LinkedList();
+ byStream[streamId] = q;
+ roundRobinOrder.Add(streamId);
+ }
+
+ if (q.Count >= HardMaxFramesPerStream)
+ return false;
+
+ q.AddLast(new PendingFrame(rented, offset, payloadLength, endStream));
+ return true;
+ }
+ }
+
+ /// Drops and returns all queued buffers for a closed/reset stream.
+ public void CancelStream(int streamId)
+ {
+ LinkedList? q;
+ lock (gate)
+ {
+ endStreamHandedOff.Remove(streamId);
+ if (!byStream.Remove(streamId, out q))
+ return;
+ roundRobinOrder.Remove(streamId);
+ if (roundRobinIndex >= roundRobinOrder.Count)
+ roundRobinIndex = 0;
+ }
+
+ while (q.Count > 0)
+ {
+ var frame = q.First!.Value;
+ q.RemoveFirst();
+ ArrayPool.Shared.Return(frame.Rented);
+ }
+ }
+
+ ///
+ /// Writes as many deferred frames as currently allows, round-robin across
+ /// streams so one stream cannot spend the whole connection window. Returns true if any bytes were
+ /// enqueued to .
+ /// runs under this queue's lock when END_STREAM is handed to
+ /// , before returns, so a concurrent reader that
+ /// takes the same lock (for example ) observes the half-closed flag
+ /// before it can see an empty queue. It must not call back into this instance.
+ /// runs after the lock is released with the same stream id and
+ /// may remove the stream (which cancels this queue).
+ ///
+ public bool TryDrain(Http2FlowController flow, Http2FrameWriter writer,
+ Action? onEndStreamSent = null, Action? onEndStreamQueued = null)
+ {
+ var wrote = false;
+ List? endedStreams = null;
+
+ lock (gate)
+ {
+ if (roundRobinOrder.Count == 0)
+ return false;
+
+ var idlePasses = 0;
+ while (idlePasses < roundRobinOrder.Count)
+ {
+ if (roundRobinOrder.Count == 0)
+ break;
+
+ if (roundRobinIndex >= roundRobinOrder.Count)
+ roundRobinIndex = 0;
+
+ var streamId = roundRobinOrder[roundRobinIndex];
+ if (!byStream.TryGetValue(streamId, out var q) || q.Count == 0)
+ {
+ byStream.Remove(streamId);
+ roundRobinOrder.RemoveAt(roundRobinIndex);
+ idlePasses = 0;
+ continue;
+ }
+
+ var pending = q.First!.Value;
+ // Empty END_STREAM (or empty DATA) needs no send-window credit but must still be
+ // framed — otherwise a queued trailer END_STREAM behind deferred body bytes stalls forever.
+ if (pending.PayloadLength <= 0)
+ {
+ idlePasses = 0;
+ q.RemoveFirst();
+ EnqueueDataFrame(writer, streamId, ReadOnlySpan.Empty, pending.EndStream);
+ wrote = true;
+ ArrayPool.Shared.Return(pending.Rented);
+ if (pending.EndStream)
+ NoteEndStream(streamId, onEndStreamQueued, ref endedStreams);
+
+ if (q.Count == 0)
+ {
+ byStream.Remove(streamId);
+ roundRobinOrder.RemoveAt(roundRobinIndex);
+ if (roundRobinOrder.Count == 0)
+ roundRobinIndex = 0;
+ else if (roundRobinIndex >= roundRobinOrder.Count)
+ roundRobinIndex = 0;
+ }
+ else
+ {
+ roundRobinIndex = (roundRobinIndex + 1) % roundRobinOrder.Count;
+ }
+
+ continue;
+ }
+
+ var reserved = flow.TryReservePartial(streamId, pending.PayloadLength);
+ if (reserved <= 0)
+ {
+ roundRobinIndex = (roundRobinIndex + 1) % roundRobinOrder.Count;
+ idlePasses++;
+ continue;
+ }
+
+ idlePasses = 0;
+ q.RemoveFirst();
+
+ var remaining = pending.PayloadLength - reserved;
+ var endStreamNow = pending.EndStream && remaining == 0;
+ EnqueueDataFrame(writer, streamId, pending.Rented.AsSpan(pending.Offset, reserved), endStreamNow);
+ wrote = true;
+
+ if (remaining > 0)
+ {
+ // Remainder must stay at the front of this stream's queue. Enqueue-at-back would
+ // send later frames before finishing this one (corrupt POST bodies under a short
+ // connection window).
+ q.AddFirst(new PendingFrame(pending.Rented, pending.Offset + reserved, remaining,
+ pending.EndStream));
+ }
+ else
+ {
+ ArrayPool.Shared.Return(pending.Rented);
+ if (endStreamNow)
+ NoteEndStream(streamId, onEndStreamQueued, ref endedStreams);
+ }
+
+ if (q.Count == 0)
+ {
+ byStream.Remove(streamId);
+ roundRobinOrder.RemoveAt(roundRobinIndex);
+ if (roundRobinOrder.Count == 0)
+ roundRobinIndex = 0;
+ else if (roundRobinIndex >= roundRobinOrder.Count)
+ roundRobinIndex = 0;
+ }
+ else
+ {
+ // Give other streams a turn after a partial write.
+ roundRobinIndex = (roundRobinIndex + 1) % roundRobinOrder.Count;
+ }
+ }
+ }
+
+ FlushEndStreamSentCallbacks(endedStreams, onEndStreamSent);
+ return wrote;
+ }
+
+ ///
+ /// Record under the lock, before / later
+ /// onEndStreamSent → RemoveStream → CancelStream, which would Remove()
+ /// mid-iteration. The handed-off set stays until
+ /// so a concurrent RST still sees the response as in flight
+ /// after the queue hits zero.
+ ///
+ private void NoteEndStream(int endedStreamId, Action? onEndStreamQueued, ref List? endedStreams)
+ {
+ endStreamHandedOff.Add(endedStreamId);
+ onEndStreamQueued?.Invoke(endedStreamId);
+ endedStreams ??= new List();
+ endedStreams.Add(endedStreamId);
+ }
+
+ private static void FlushEndStreamSentCallbacks(List? endedStreams, Action? onEndStreamSent)
+ {
+ if (endedStreams is null || onEndStreamSent is null)
+ return;
+
+ foreach (var id in endedStreams)
+ onEndStreamSent(id);
+ }
+
+ private static void EnqueueDataFrame(Http2FrameWriter writer, int streamId, ReadOnlySpan payload,
+ bool endStream)
+ {
+ var total = 9 + payload.Length;
+ var rented = ArrayPool.Shared.Rent(total);
+ var header = new Http2FrameHeader
+ {
+ Length = payload.Length,
+ Type = Http2FrameType.Data,
+ Flags = endStream ? Http2FrameFlag.EndStream : 0,
+ StreamId = streamId
+ };
+ header.CopyToBuffer(rented);
+ payload.CopyTo(rented.AsSpan(9));
+ writer.EnqueueRented(rented, total);
+ }
+}
diff --git a/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs b/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs
index 9d115ba43..2c18f947b 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs
@@ -160,16 +160,56 @@ public bool TryReserve(int streamId, int bytes)
}
}
+ ///
+ /// Non-blocking reserve of as many of as currently fit in both the
+ /// connection window and the stream window (Kestrel CheckStreamWindow shape). Returns the
+ /// reserved count (0..); never waits. Used by the compressed-relay DATA
+ /// path so a short client window cannot park the shared origin frame reader.
+ ///
+ public int TryReservePartial(int streamId, int bytes)
+ {
+ if (bytes <= 0) return 0;
+
+ lock (gate)
+ {
+ if (!streamWindows.TryGetValue(streamId, out var streamWindow))
+ {
+ streamWindow = initialStreamWindow;
+ streamWindows[streamId] = streamWindow;
+ }
+
+ var streamAvail = streamWindow > 0 ? streamWindow : 0;
+ var connAvail = connectionWindow > 0 ? connectionWindow : 0;
+ var available = (int)Math.Min(Math.Min(streamAvail, connAvail), bytes);
+ if (available <= 0)
+ return 0;
+
+ connectionWindow -= available;
+ streamWindows[streamId] = streamWindow - available;
+ return available;
+ }
+ }
+
///
/// Waits until both the connection window and the given stream's window have at least
/// of credit, then atomically reserves (decrements) both. Must be called
/// with the exact on-wire payload length of the DATA frame that is about to be written, before it is
/// written, for every outbound DATA frame on the leg this controller governs.
///
- public async Task ReserveAsync(int streamId, int bytes, CancellationToken cancellationToken)
+ public ValueTask ReserveAsync(int streamId, int bytes, CancellationToken cancellationToken)
{
- if (bytes <= 0) return;
+ if (bytes <= 0) return default;
+
+ // Prefer non-blocking reserve when the peer window already has room (typical after
+ // SETTINGS / WINDOW_UPDATE); avoid a Task/state-machine alloc per DATA frame.
+ if (TryReserve(streamId, bytes))
+ return default;
+ return ReserveSlowAsync(streamId, bytes, cancellationToken);
+ }
+
+ private async ValueTask ReserveSlowAsync(int streamId, int bytes, CancellationToken cancellationToken)
+ {
while (true)
{
Task wait;
diff --git a/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs b/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs
index 6c868d448..ace95cfbf 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs
@@ -175,17 +175,56 @@ public void EnqueueRented(byte[] rented, int length)
}
}
- private async Task WriteLockedAsync(ReadOnlyMemory memory, CancellationToken cancellationToken)
+ private ValueTask WriteLockedAsync(ReadOnlyMemory memory, CancellationToken cancellationToken)
{
- if (writeLock != null)
- await writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ if (writeLock == null)
+ return output.WriteAsync(memory, cancellationToken);
+
+ var waitTask = writeLock.WaitAsync(cancellationToken);
+ if (!waitTask.IsCompletedSuccessfully)
+ return WriteLockedSlowAsync(waitTask, memory, cancellationToken);
+
+ try
+ {
+ var writeVt = output.WriteAsync(memory, cancellationToken);
+ if (writeVt.IsCompletedSuccessfully)
+ {
+ writeLock.Release();
+ return default;
+ }
+
+ return WriteLockedAwaitWriteAsync(writeVt);
+ }
+ catch
+ {
+ writeLock.Release();
+ throw;
+ }
+ }
+
+ private async ValueTask WriteLockedSlowAsync(Task waitTask, ReadOnlyMemory memory,
+ CancellationToken cancellationToken)
+ {
+ await waitTask.ConfigureAwait(false);
try
{
await output.WriteAsync(memory, cancellationToken).ConfigureAwait(false);
}
finally
{
- writeLock?.Release();
+ writeLock!.Release();
+ }
+ }
+
+ private async ValueTask WriteLockedAwaitWriteAsync(ValueTask writeVt)
+ {
+ try
+ {
+ await writeVt.ConfigureAwait(false);
+ }
+ finally
+ {
+ writeLock!.Release();
}
}
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
index 0795136eb..963a166e1 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
@@ -414,8 +414,8 @@ internal partial class Http2Helper
// Drain queued HEADERS/DATA so trailers cannot overtake them on the wire.
if (isClient)
await connectionState.ServerWriteChain;
- await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer,
- hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output)));
+ await lockedOutputWrite(() => SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer,
+ hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output));
}
return false;
@@ -1462,8 +1462,8 @@ await RelayCompressedHeaderBlockAsync(
// Drain queued response HEADERS/DATA so trailers cannot overtake them.
await connectionState.ClientWriteChain;
- await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer,
- hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output)));
+ await lockedOutputWrite(() => SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer,
+ hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output));
return false;
}
}
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
index 9af42807a..fbb015335 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
@@ -407,6 +407,8 @@ void RemoveAndFinalizeStream(int removeStreamId)
removedState.Cancellation.Dispose();
connectionState.ClientSendFlow.RemoveStream(removeStreamId);
connectionState.ServerSendFlow.RemoveStream(removeStreamId);
+ connectionState.ClientOutboundDeferred.CancelStream(removeStreamId);
+ connectionState.ServerOutboundDeferred.CancelStream(removeStreamId);
ScheduleFinalize(removedState, onAfterResponse, logger, connectionState);
}
}
@@ -584,7 +586,12 @@ async Task TrySendGracefulGoAwayAsync()
|| (!isClient && compressedDataState.ResponseDataCompressedRelay)))
{
bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0;
+ bool dataPadded = (flags & Http2FrameFlag.Padded) != 0;
var creditStreamId = originReceiveLeg != null ? peerStreamId : dataStreamId;
+
+ // Always grant receive credit for bytes already read. Soft-throttle without a
+ // withheld-credit reclaim would permanently shrink the peer's send window.
+ // Hard queue cap + RST still bounds memory if the client is slow.
if (dataEndStream)
{
// Tiny-GET hot path: END_STREAM closes the stream — skip stream WINDOW_UPDATE
@@ -612,14 +619,18 @@ async Task TrySendGracefulGoAwayAsync()
}
Http2FrameWriter? dedicatedWriter = null;
+ Http2FlowController sendFlow = outboundFlow;
+ Http2DeferredOutboundData sendDeferred = !isClient
+ ? connectionState.ClientOutboundDeferred
+ : connectionState.ServerOutboundDeferred;
+ var wireStreamId = dataStreamId;
if (isClient && connectionState.OriginRelayPool != null
&& connectionState.OriginRelayPool.TryGetAssignment(dataStreamId, out var assignment))
{
- var wireStreamId = assignment.OriginStreamId;
+ wireStreamId = assignment.OriginStreamId;
dedicatedWriter = assignment.Leg.Writer;
- await assignment.Leg.SendFlow
- .ReserveAsync(wireStreamId, length, cancellationToken)
- .ConfigureAwait(false);
+ sendFlow = assignment.Leg.SendFlow;
+ sendDeferred = assignment.Leg.OutboundDeferred;
frameHeader.StreamId = wireStreamId;
frameHeaderBuffer[5] = (byte)((wireStreamId >> 24) & 0x7f);
frameHeaderBuffer[6] = (byte)((wireStreamId >> 16) & 0xff);
@@ -629,32 +640,53 @@ await assignment.Leg.SendFlow
else if (!isClient && originReceiveLeg != null)
{
dedicatedWriter = connectionState.ClientFrameWriter;
- await outboundFlow.ReserveAsync(dataStreamId, length, cancellationToken);
}
else
{
- await outboundFlow.ReserveAsync(dataStreamId, length, cancellationToken);
+ dedicatedWriter = isClient
+ ? connectionState.ServerFrameWriter
+ : connectionState.ClientFrameWriter;
}
- var wireLen = 9 + length;
- var rented = ArrayPool.Shared.Rent(wireLen);
- frameHeader.CopyToBuffer(rented);
- if (length > 0 && !await intake.ReadExactAsync(rented, 9, length, cancellationToken))
+ // Read payload first (never park the frame loop on send-window credit).
+ var payloadRented = length > 0
+ ? ArrayPool.Shared.Rent(length)
+ : Array.Empty();
+ if (length > 0 && !await intake.ReadExactAsync(payloadRented, 0, length, cancellationToken))
{
- ArrayPool.Shared.Return(rented);
+ if (length > 0)
+ ArrayPool.Shared.Return(payloadRented);
await TrySendGracefulGoAwayAsync();
return;
}
- if (dedicatedWriter != null)
- dedicatedWriter.EnqueueRented(rented, wireLen);
- else
- connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output,
- rented, wireLen);
+ // Request half already closed (e.g. absorbed origin RST after early response):
+ // consume bytes for flow-control hygiene but do not forward toward a dead origin.
+ if (isClient && compressedDataState.RequestClosed)
+ {
+ if (length > 0)
+ ArrayPool.Shared.Return(payloadRented);
+ if (dataEndStream)
+ {
+ // already RequestClosed; may now finalize if response half is done
+ if (compressedDataState.IsClosed)
+ {
+ connectionState.OriginRelayPool?.ReleaseStream(dataStreamId);
+ connectionState.RemoveStream(dataStreamId);
+ ScheduleFinalize(compressedDataState, onAfterResponse, logger,
+ connectionState);
+ }
+ }
+
+ continue;
+ }
- if (dataEndStream
- && connectionState.Streams.TryGetValue(dataStreamId, out var closingCompressed))
+ void MarkCompressedClosedIfFullySent()
{
+ if (!dataEndStream)
+ return;
+ if (!connectionState.Streams.TryGetValue(dataStreamId, out var closingCompressed))
+ return;
if (isClient)
closingCompressed.RequestClosed = true;
else
@@ -668,6 +700,127 @@ await assignment.Leg.SendFlow
}
}
+ void EnqueueWireFrame(ReadOnlySpan payload, bool endStream)
+ {
+ var wireLen = 9 + payload.Length;
+ var rented = ArrayPool.Shared.Rent(wireLen);
+ var hdr = new Http2FrameHeader
+ {
+ Length = payload.Length,
+ Type = Http2FrameType.Data,
+ Flags = endStream ? Http2FrameFlag.EndStream : 0,
+ StreamId = wireStreamId
+ };
+ hdr.CopyToBuffer(rented);
+ payload.CopyTo(rented.AsSpan(9));
+ if (dedicatedWriter != null)
+ dedicatedWriter.EnqueueRented(rented, wireLen);
+ else
+ connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output,
+ rented, wireLen);
+ }
+
+ // Padded frames are not split (pad length + padding layout); defer whole frame.
+ var reserveBytes = length;
+
+ // Never await ReserveAsync on either DATA direction: parking the shared frame
+ // reader HOL-blocks WINDOW_UPDATE (early-response / duplex H2↔H2: client
+ // WINDOW_UPDATE cannot drain deferred response DATA while request DATA waits).
+ // Partial+defer + AddFirst remainder keeps wire order; PendingCount overtake
+ // guard prevents END_STREAM racing ahead of queued bytes.
+
+ // If this stream already has deferred DATA, do not send the new frame (or an
+ // empty END_STREAM) immediately — that would overtake queued bytes and truncate
+ // the body under a slow client (arch slow-consumer 256 KiB short-read at 65535).
+ if (sendDeferred.PendingCount(wireStreamId) > 0)
+ {
+ if (length == 0 && !dataEndStream)
+ {
+ // nothing to forward
+ }
+ else
+ {
+ var toEnqueue = length > 0
+ ? payloadRented
+ : ArrayPool.Shared.Rent(1);
+ var ownedPayload = length > 0;
+ if (!sendDeferred.TryEnqueue(wireStreamId, toEnqueue, 0, length, dataEndStream))
+ {
+ ArrayPool.Shared.Return(toEnqueue);
+ if (ownedPayload)
+ payloadRented = Array.Empty(); // already returned
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 deferred DATA queue exceeded its per-stream cap.", null, null));
+ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(),
+ new byte[9], peerStreamId != 0 ? peerStreamId : dataStreamId,
+ Http2ErrorCode.EnhanceYourCalm, input));
+ connectionState.RemoveStream(dataStreamId);
+ }
+ else if (ownedPayload)
+ {
+ payloadRented = Array.Empty(); // ownership transferred
+ }
+ }
+
+ if (length > 0 && payloadRented.Length > 0)
+ ArrayPool.Shared.Return(payloadRented);
+ continue;
+ }
+
+ var reserved = sendFlow.TryReservePartial(wireStreamId, reserveBytes);
+ if (dataPadded && reserved > 0 && reserved < reserveBytes)
+ {
+ // Undo partial reserve — cannot emit a valid short padded frame.
+ sendFlow.OnWindowUpdate(wireStreamId, reserved);
+ sendFlow.OnWindowUpdate(0, reserved);
+ reserved = 0;
+ }
+
+ if (reserved == reserveBytes)
+ {
+ EnqueueWireFrame(length == 0
+ ? ReadOnlySpan.Empty
+ : payloadRented.AsSpan(0, length), dataEndStream);
+ if (length > 0)
+ ArrayPool.Shared.Return(payloadRented);
+ MarkCompressedClosedIfFullySent();
+ }
+ else if (reserved > 0)
+ {
+ EnqueueWireFrame(payloadRented.AsSpan(0, reserved), endStream: false);
+ var rem = length - reserved;
+ if (!sendDeferred.TryEnqueue(wireStreamId, payloadRented, reserved, rem, dataEndStream))
+ {
+ ArrayPool.Shared.Return(payloadRented);
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 deferred DATA queue exceeded its per-stream cap.", null, null));
+ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(),
+ new byte[9], peerStreamId != 0 ? peerStreamId : dataStreamId,
+ Http2ErrorCode.EnhanceYourCalm, input));
+ connectionState.RemoveStream(dataStreamId);
+ }
+ }
+ else if (length == 0 && dataEndStream)
+ {
+ // Empty END_STREAM DATA — still needs a frame on the wire (no window cost).
+ EnqueueWireFrame(ReadOnlySpan.Empty, endStream: true);
+ MarkCompressedClosedIfFullySent();
+ }
+ else if (length == 0)
+ {
+ // Empty non-END_STREAM DATA — nothing to forward.
+ }
+ else if (!sendDeferred.TryEnqueue(wireStreamId, payloadRented, 0, length, dataEndStream))
+ {
+ ArrayPool.Shared.Return(payloadRented);
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 deferred DATA queue exceeded its per-stream cap.", null, null));
+ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(),
+ new byte[9], peerStreamId != 0 ? peerStreamId : dataStreamId,
+ Http2ErrorCode.EnhanceYourCalm, input));
+ connectionState.RemoveStream(dataStreamId);
+ }
+
continue;
}
@@ -1519,6 +1672,75 @@ await QueueSendData(connectionState, towardServer: isClient, outputWriteLock,
await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
peerStreamId, Http2ErrorCode.FlowControlError, input));
}
+ else
+ {
+ // Resume DATA that was deferred so the peer frame loop never parked on ReserveAsync.
+ // Mark the half inside the drain lock; RemoveStream only after the lock drops
+ // (CancelStream mutates the round-robin list mid-iteration).
+ bool TryResolveDeferredStream(int deferredStreamId, out int clientStreamId,
+ out Http2StreamState? resolved)
+ {
+ clientStreamId = deferredStreamId;
+ if (originReceiveLeg != null
+ && originReceiveLeg.OriginToClient.TryGetValue(deferredStreamId, out var mapped))
+ clientStreamId = mapped;
+
+ return connectionState.Streams.TryGetValue(clientStreamId, out resolved);
+ }
+
+ void MarkDeferredHalfClosed(int deferredStreamId)
+ {
+ if (!TryResolveDeferredStream(deferredStreamId, out _, out var st) || st == null)
+ return;
+
+ // Client WINDOW_UPDATE resumes origin→client DATA (response).
+ // Origin WINDOW_UPDATE resumes client→origin DATA (request).
+ // Flags only: channel completion and RemoveStream run after the lock.
+ if (isClient && originReceiveLeg == null)
+ st.ResponseClosed = true;
+ else
+ st.RequestClosed = true;
+ }
+
+ void OnDeferredEndStream(int deferredStreamId)
+ {
+ if (!TryResolveDeferredStream(deferredStreamId, out var clientStreamId, out var st) ||
+ st == null)
+ return;
+
+ // Idempotent with MarkDeferredHalfClosed (already run under the drain lock).
+ MarkDeferredHalfClosed(deferredStreamId);
+ if (st.RequestClosed && st.IsExtendedConnect)
+ st.InboundTunnelChannel?.Writer.TryComplete();
+
+ if (st.IsClosed)
+ {
+ connectionState.OriginRelayPool?.ReleaseStream(clientStreamId);
+ connectionState.RemoveStream(clientStreamId);
+ ScheduleFinalize(st, onAfterResponse, logger, connectionState);
+ }
+ }
+
+ if (originReceiveLeg != null)
+ {
+ originReceiveLeg.OutboundDeferred.TryDrain(flow, originReceiveLeg.Writer,
+ OnDeferredEndStream, MarkDeferredHalfClosed);
+ }
+ else if (isClient)
+ {
+ var writer = connectionState.ClientFrameWriter;
+ if (writer != null)
+ connectionState.ClientOutboundDeferred.TryDrain(flow, writer,
+ OnDeferredEndStream, MarkDeferredHalfClosed);
+ }
+ else
+ {
+ var writer = connectionState.ServerFrameWriter;
+ if (writer != null)
+ connectionState.ServerOutboundDeferred.TryDrain(flow, writer,
+ OnDeferredEndStream, MarkDeferredHalfClosed);
+ }
+ }
}
}
else if (type == Http2FrameType.Ping)
@@ -1944,6 +2166,45 @@ await lockedOwnLegWrite(async () =>
int errorCode = ReadHttp2ErrorCode(buffer);
+ // Origin NO_ERROR / CANCEL / STREAM_CLOSED after (or while) completing a response is
+ // normal cleanup of an unread request body (early-response / duplex). Forwarding that
+ // RST to the client while deferred response DATA is still queued — or after the
+ // response half is already closed — makes HttpClient fail mid-CopyTo with NO_ERROR.
+ // Absorb it: stop feeding the origin, keep draining toward the client.
+ var benignOriginRst = !isClient
+ && (errorCode == (int)Http2ErrorCode.NoError
+ || errorCode == (int)Http2ErrorCode.Cancel
+ || errorCode == (int)Http2ErrorCode.StreamClosed);
+ if (benignOriginRst
+ && connectionState.Streams.TryGetValue(streamId, out var absorbState))
+ {
+ // HasOutboundInFlight covers queued DATA and END_STREAM already handed to the
+ // writer (pending count is 0 in that window). ResponseClosed covers the
+ // fast path that never entered the deferred queue.
+ var responseStillInFlight =
+ connectionState.ClientOutboundDeferred.HasOutboundInFlight(streamId)
+ || absorbState.ResponseClosed;
+ if (responseStillInFlight)
+ {
+ sendPacket = false;
+ connectionState.ServerOutboundDeferred.CancelStream(streamId);
+ absorbState.RequestClosed = true;
+ if (absorbState.IsClosed)
+ {
+ connectionState.OriginRelayPool?.ReleaseStream(streamId);
+ connectionState.RemoveStream(streamId);
+ ScheduleFinalize(absorbState, onAfterResponse, logger, connectionState);
+ }
+ else
+ {
+ // Response DATA still draining; do not tear the stream down yet.
+ connectionState.OriginRelayPool?.ReleaseStream(streamId);
+ }
+
+ continue;
+ }
+ }
+
// stream error: cancel any waiter/synthetic task scoped to this stream and stop tracking
// its flow-control windows and session mapping - regardless of the error code, the
// stream is now closed.
@@ -2180,12 +2441,16 @@ await lockedOwnLegWrite(async () =>
else
await connectionState.ClientWriteChain;
await lockedOutputWrite(() =>
- AsValueTask(SendBody(remoteSettings, rr, frameHeader, frameHeaderBuffer, buffer, outboundFlow,
- output, cancellationToken)));
+ SendBody(remoteSettings, rr, frameHeader, frameHeaderBuffer, buffer, outboundFlow,
+ output, cancellationToken));
}
}
- if (endStream)
+ // DATA with END_STREAM may be partially deferred for send-window credit; do not
+ // half-close / finalize until the last byte is actually queued (see sendPacket /
+ // OnDeferredEndStream). Non-DATA END_STREAM (HEADERS) and DATA that is not forwarded
+ // still close here.
+ if (endStream && (type != Http2FrameType.Data || !sendPacket))
{
if (isClient)
connectionState.MultipartObservers.TryRemove(streamId, out _);
@@ -2216,46 +2481,172 @@ await lockedOutputWrite(() =>
if (type == Http2FrameType.Data)
{
+ // Non-blocking send-window reserve: never park the shared frame reader.
+ Http2FrameWriter? dedicatedWriter = null;
+ Http2FlowController sendFlow = outboundFlow;
+ Http2DeferredOutboundData sendDeferred = isClient
+ ? connectionState.ServerOutboundDeferred
+ : connectionState.ClientOutboundDeferred;
+ var wireStreamId = streamId;
+ var dataEndStream = (flags & Http2FrameFlag.EndStream) != 0;
+ var dataPadded = (flags & Http2FrameFlag.Padded) != 0;
+
+ if (dataEndStream && isClient)
+ connectionState.MultipartObservers.TryRemove(streamId, out _);
+
if (isClient && connectionState.OriginRelayPool != null
- && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var dataAssignment))
+ && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var assignment))
{
- await dataAssignment.Leg.SendFlow
- .ReserveAsync(dataAssignment.OriginStreamId, frameLength, cancellationToken)
- .ConfigureAwait(false);
+ wireStreamId = assignment.OriginStreamId;
+ frameHeader.StreamId = wireStreamId;
+ dedicatedWriter = assignment.Leg.Writer;
+ sendFlow = assignment.Leg.SendFlow;
+ sendDeferred = assignment.Leg.OutboundDeferred;
+ }
+ else if (!isClient && originReceiveLeg != null)
+ {
+ dedicatedWriter = connectionState.ClientFrameWriter;
}
else
{
- await outboundFlow.ReserveAsync(streamId, frameLength, cancellationToken);
+ dedicatedWriter = isClient
+ ? connectionState.ServerFrameWriter
+ : connectionState.ClientFrameWriter;
}
- }
- if (type == Http2FrameType.Data)
- {
- // Copy and queue so DATA cannot overtake a queued HEADERS write on this direction
- // (and so the frame loop does not await peer socket I/O on the hot path).
- Http2FrameWriter? dedicatedWriter = null;
- if (isClient && connectionState.OriginRelayPool != null
- && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var assignment))
+ void MarkNonRelayClosedIfFullySent()
{
- frameHeader.StreamId = assignment.OriginStreamId;
- dedicatedWriter = assignment.Leg.Writer;
+ if (!dataEndStream)
+ return;
+ if (!connectionState.Streams.TryGetValue(streamId, out var closing))
+ return;
+ if (isClient)
+ {
+ closing.RequestClosed = true;
+ if (closing.IsExtendedConnect)
+ closing.InboundTunnelChannel?.Writer.TryComplete();
+ }
+ else
+ closing.ResponseClosed = true;
+
+ if (closing.IsClosed)
+ {
+ connectionState.OriginRelayPool?.ReleaseStream(streamId);
+ connectionState.RemoveStream(streamId);
+ ScheduleFinalize(closing, onAfterResponse, logger, connectionState);
+ }
}
- else if (!isClient && originReceiveLeg != null)
+
+ void EnqueueNonRelayWire(ReadOnlySpan payload, bool endStreamFlag)
{
- dedicatedWriter = connectionState.ClientFrameWriter;
+ var wl = 9 + payload.Length;
+ var rentedFrame = ArrayPool.Shared.Rent(wl);
+ var hdr = new Http2FrameHeader
+ {
+ Length = payload.Length,
+ Type = Http2FrameType.Data,
+ Flags = endStreamFlag ? Http2FrameFlag.EndStream : 0,
+ StreamId = wireStreamId
+ };
+ hdr.CopyToBuffer(rentedFrame);
+ payload.CopyTo(rentedFrame.AsSpan(9));
+ if (dedicatedWriter != null)
+ dedicatedWriter.EnqueueRented(rentedFrame, wl);
+ else
+ connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output,
+ rentedFrame, wl);
}
- frameHeader.CopyToBuffer(frameHeaderBuffer);
- var wireLen = 9 + frameLength;
- var rented = ArrayPool.Shared.Rent(wireLen);
- frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented);
- if (frameLength > 0)
- buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9));
- if (dedicatedWriter != null)
- dedicatedWriter.EnqueueRented(rented, wireLen);
+ async ValueTask RejectDeferredCapAsync()
+ {
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 deferred DATA queue exceeded its per-stream cap.", null, args));
+ await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(),
+ new byte[9], streamId, Http2ErrorCode.EnhanceYourCalm, input));
+ // Cap overflow must drop the stream. Leaving it tracked stalls the peer
+ // after ENHANCE_YOUR_CALM with no half-close and no AfterResponse.
+ RemoveAndFinalizeStream(streamId);
+ }
+
+ // Pending before reserve: the WINDOW_UPDATE drain runs on the other leg.
+ // Reserving credit and then queueing the frame (because older DATA is still
+ // deferred) permanently shrinks the send window and stalls the stream.
+ if (sendDeferred.PendingCount(wireStreamId) > 0)
+ {
+ if (!(frameLength == 0 && !dataEndStream))
+ {
+ var payloadRented = ArrayPool.Shared.Rent(Math.Max(1, frameLength));
+ if (frameLength > 0)
+ buffer.AsSpan(0, frameLength).CopyTo(payloadRented);
+ if (!sendDeferred.TryEnqueue(wireStreamId, payloadRented, 0, frameLength,
+ dataEndStream))
+ {
+ ArrayPool.Shared.Return(payloadRented);
+ await RejectDeferredCapAsync();
+ }
+ }
+ }
else
- connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output, rented,
- wireLen);
+ {
+ var reserved = sendFlow.TryReservePartial(wireStreamId, frameLength);
+ if (dataPadded && reserved > 0 && reserved < frameLength)
+ {
+ sendFlow.OnWindowUpdate(wireStreamId, reserved);
+ sendFlow.OnWindowUpdate(0, reserved);
+ reserved = 0;
+ }
+
+ if (reserved == frameLength)
+ {
+ frameHeader.CopyToBuffer(frameHeaderBuffer);
+ var wireLen = 9 + frameLength;
+ var rented = ArrayPool.Shared.Rent(wireLen);
+ frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented);
+ if (frameLength > 0)
+ buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9));
+ if (dedicatedWriter != null)
+ dedicatedWriter.EnqueueRented(rented, wireLen);
+ else
+ connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output,
+ rented, wireLen);
+ MarkNonRelayClosedIfFullySent();
+ }
+ else if (reserved > 0)
+ {
+ EnqueueNonRelayWire(buffer.AsSpan(0, reserved), endStreamFlag: false);
+ var rem = frameLength - reserved;
+ var payloadRented = ArrayPool.Shared.Rent(rem);
+ buffer.AsSpan(reserved, rem).CopyTo(payloadRented);
+ if (!sendDeferred.TryEnqueue(wireStreamId, payloadRented, 0, rem, dataEndStream))
+ {
+ ArrayPool.Shared.Return(payloadRented);
+ await RejectDeferredCapAsync();
+ }
+ }
+ else if (frameLength == 0)
+ {
+ frameHeader.CopyToBuffer(frameHeaderBuffer);
+ var wireLen = 9;
+ var rented = ArrayPool.Shared.Rent(wireLen);
+ frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented);
+ if (dedicatedWriter != null)
+ dedicatedWriter.EnqueueRented(rented, wireLen);
+ else
+ connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output,
+ rented, wireLen);
+ MarkNonRelayClosedIfFullySent();
+ }
+ else
+ {
+ var payloadRented = ArrayPool.Shared.Rent(frameLength);
+ buffer.AsSpan(0, frameLength).CopyTo(payloadRented);
+ if (!sendDeferred.TryEnqueue(wireStreamId, payloadRented, 0, frameLength, dataEndStream))
+ {
+ ArrayPool.Shared.Return(payloadRented);
+ await RejectDeferredCapAsync();
+ }
+ }
+ }
}
else
{
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs
index 504be996f..49612b99b 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs
@@ -31,13 +31,13 @@ namespace Titanium.Web.Proxy.Http2
{
internal partial class Http2Helper
{
- internal static async Task SendHeader(Http2Settings settings, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
+ internal static ValueTask SendHeader(Http2Settings settings, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
{
// Same HPACK lock as QueueSendHeader: Encoder + encode scratch are connection-direction scoped.
ReadOnlyMemory block;
lock (settings.Sync)
block = EncodeHeaderBlock(settings, rr).ToArray();
- await WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, frameHeader.StreamId,
+ return WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, frameHeader.StreamId,
pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream,
rr.Priority.HasValue, block, settings.MaxFrameSize, output);
}
@@ -165,7 +165,7 @@ private static void QueueRstStreamFrame(Http2ConnectionState connectionState, St
/// encoder as so the destination's dynamic table stays in sync
/// regardless of whether trailers are actually present on a given message.
///
- internal static async Task SendTrailer(Http2Settings settings, Http2FrameHeader frameHeader,
+ internal static ValueTask SendTrailer(Http2Settings settings, Http2FrameHeader frameHeader,
byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream, Stream output)
{
ReadOnlyMemory block;
@@ -204,7 +204,7 @@ internal static async Task SendTrailer(Http2Settings settings, Http2FrameHeader
block = GetMemoryStreamMemory(ms).ToArray();
}
- await WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, streamId, Http2FrameType.Headers,
+ return WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, streamId, Http2FrameType.Headers,
endStream, false, block, settings.MaxFrameSize, output);
}
@@ -303,7 +303,7 @@ private static void CopyHeaderBlockSegment(ReadOnlyMemory data, ReadOnlyMe
/// on the first, matching the semantics of the frame types they belong to. HEADERS/CONTINUATION
/// frames are not subject to flow control (RFC 7540 ?6.9), so no reservation is made here.
///
- private static async Task WriteHeaderBlockAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, // NOSONAR S107 -- Frame fields are kept explicit in this low-level encoder helper.
+ private static ValueTask WriteHeaderBlockAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, // NOSONAR S107 -- Frame fields are kept explicit in this low-level encoder helper.
int streamId, Http2FrameType type, bool endStream, bool hasPriority, ReadOnlyMemory data,
int maxFrameSize, Stream output)
{
@@ -311,6 +311,29 @@ private static async Task WriteHeaderBlockAsync(Http2FrameHeader frameHeader, by
frameHeader.StreamId = streamId;
+ // Common case: one frame fits — WriteTwoAsync already sync-completes when possible.
+ if (data.Length <= maxFrameSize)
+ {
+ frameHeader.Type = type;
+ frameHeader.Length = data.Length;
+ var flags = Http2FrameFlag.EndHeaders;
+ if (endStream) flags |= Http2FrameFlag.EndStream;
+ if (hasPriority) flags |= Http2FrameFlag.Priority;
+ frameHeader.Flags = flags;
+ frameHeader.CopyToBuffer(frameHeaderBuffer);
+ return WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), data);
+ }
+
+ return WriteHeaderBlockMultiAsync(frameHeader, frameHeaderBuffer, streamId, type, endStream,
+ hasPriority, data, maxFrameSize, output);
+ }
+
+ private static async ValueTask WriteHeaderBlockMultiAsync(Http2FrameHeader frameHeader,
+ byte[] frameHeaderBuffer, int streamId, Http2FrameType type, bool endStream, bool hasPriority,
+ ReadOnlyMemory data, int maxFrameSize, Stream output)
+ {
+ frameHeader.StreamId = streamId;
+
var pos = 0;
var first = true;
do
@@ -323,9 +346,7 @@ private static async Task WriteHeaderBlockAsync(Http2FrameHeader frameHeader, by
var flags = (Http2FrameFlag)0;
if (isLast)
- {
flags |= Http2FrameFlag.EndHeaders;
- }
if (first)
{
@@ -336,15 +357,14 @@ private static async Task WriteHeaderBlockAsync(Http2FrameHeader frameHeader, by
frameHeader.Flags = flags;
frameHeader.CopyToBuffer(frameHeaderBuffer);
- await output.WriteAsync(frameHeaderBuffer.AsMemory());
- await output.WriteAsync(data.Slice(pos, chunkLength));
+ await WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), data.Slice(pos, chunkLength));
pos += chunkLength;
first = false;
} while (pos < data.Length);
}
- internal static async Task SendBody(Http2Settings settings, RequestResponseBase rr, Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper.
+ internal static async ValueTask SendBody(Http2Settings settings, RequestResponseBase rr, Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper.
byte[] frameHeaderBuffer, byte[] buffer, Http2FlowController flow, Stream output,
CancellationToken cancellationToken)
{
@@ -371,8 +391,8 @@ private static async Task WriteHeaderBlockAsync(Http2FrameHeader frameHeader, by
frameHeader.Flags = pos < body.Length ? (Http2FrameFlag)0 : Http2FrameFlag.EndStream;
frameHeader.CopyToBuffer(frameHeaderBuffer);
- await output.WriteAsync(frameHeaderBuffer.AsMemory(), cancellationToken);
- await output.WriteAsync(buffer.AsMemory(0, bodyFrameLength), cancellationToken);
+ await WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9),
+ buffer.AsMemory(0, bodyFrameLength), cancellationToken);
}
}
}
@@ -770,7 +790,8 @@ internal static async Task EmitInterimResponseAsync(SessionEventArgs args, int s
///
internal static async Task EmitSyntheticResponseAsync(SessionEventArgs args, int streamId,
Http2ConnectionState connectionState, Stream clientStream, CancellationToken cancellationToken,
- Func? onAfterResponse = null, ILogger? logger = null)
+ Func? onAfterResponse = null, ILogger? logger = null,
+ ReadOnlyMemory? wireBody = null)
{
var response = args.HttpClient.Response;
@@ -791,6 +812,11 @@ internal static async Task EmitSyntheticResponseAsync(SessionEventArgs args, int
await EmitStreamedSyntheticResponseAsync(response, streamBodyWriter, connectionState,
frameHeader, frameHeaderBuffer, clientStream, cancellationToken);
}
+ else if (wireBody.HasValue)
+ {
+ await EmitBufferedSyntheticResponseAsync(response, streamId, connectionState, frameHeader,
+ frameHeaderBuffer, clientStream, wireBody.Value, cancellationToken);
+ }
else
{
await EmitBufferedSyntheticResponseAsync(response, streamId, connectionState, frameHeader,
@@ -854,9 +880,6 @@ private static async Task EmitBufferedSyntheticResponseAsync(Response response,
Http2ConnectionState connectionState, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer,
Stream clientStream, CancellationToken cancellationToken)
{
- var clientWriteLock = connectionState.ClientWriteLock;
- var clientSendFlow = connectionState.ClientSendFlow;
-
// buffered case (Ok/GenericResponse/Redirect/buffered Respond / H2→H3 bridge) - the whole
// body, if any, is already in memory. Compress WHILE Transfer-Encoding: chunked may still
// be present: Response.HasBody treats CL=-1 + chunked as "has body", and stripping TE
@@ -878,12 +901,27 @@ private static async Task EmitBufferedSyntheticResponseAsync(Response response,
body = response.CompressBodyAndUpdateContentLength();
}
+ await EmitBufferedSyntheticResponseAsync(response, streamId, connectionState, frameHeader,
+ frameHeaderBuffer, clientStream, body.AsMemory(), cancellationToken);
+ }
+
+ ///
+ /// Emits HEADERS + DATA from an already-buffered wire body without requiring
+ /// (coalesce path for unread MITM lite / reverse).
+ ///
+ private static async Task EmitBufferedSyntheticResponseAsync(Response response, int streamId,
+ Http2ConnectionState connectionState, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer,
+ Stream clientStream, ReadOnlyMemory wireBody, CancellationToken cancellationToken)
+ {
+ var clientWriteLock = connectionState.ClientWriteLock;
+ var clientSendFlow = connectionState.ClientSendFlow;
+
// HTTP/2 does not use chunked transfer-encoding; body framing is done via DATA frames.
response.Headers.RemoveHeader(KnownHeaders.TransferEncoding);
- if (body is { Length: > 0 } && response.ContentLength < 0)
- response.ContentLength = body.Length;
+ if (wireBody.Length > 0 && response.ContentLength < 0)
+ response.ContentLength = wireBody.Length;
- var hasBody = body is { Length: > 0 };
+ var hasBody = wireBody.Length > 0;
var maxFrameSize = connectionState.ClientSettings.MaxFrameSize;
if (maxFrameSize <= 0) maxFrameSize = 16384;
@@ -902,12 +940,12 @@ private static async Task EmitBufferedSyntheticResponseAsync(Response response,
// Reserve flow-control credit per frame before queueing so queued-but-unsent DATA can never
// exceed the client's advertised windows.
var bodyPos = 0;
- while (bodyPos < body!.Length)
+ while (bodyPos < wireBody.Length)
{
- var frameLength = Math.Min(maxFrameSize, body.Length - bodyPos);
+ var frameLength = Math.Min(maxFrameSize, wireBody.Length - bodyPos);
await clientSendFlow.ReserveAsync(streamId, frameLength, cancellationToken);
QueueDataFrame(connectionState, clientStream, streamId,
- body.AsMemory(bodyPos, frameLength), endStream: bodyPos + frameLength >= body.Length);
+ wireBody.Slice(bodyPos, frameLength), endStream: bodyPos + frameLength >= wireBody.Length);
bodyPos += frameLength;
}
}
@@ -1111,7 +1149,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer,
/// Reads origin bytes directly into pre-sized DATA frame buffers (header + payload),
/// reserves flow-control credit, and enqueues for the client frame writer.
///
- internal async Task CopyFromAsync(Func, CancellationToken, ValueTask> readAsync, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
+ internal async ValueTask CopyFromAsync(Func, CancellationToken, ValueTask> readAsync, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
CancellationToken cancellationToken)
{
while (true)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
index f432a7ff3..fa8bb46e0 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
@@ -539,7 +539,7 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream:
if (noBody)
{
response.IsBodyRead = true;
- response.Body = Array.Empty();
+ // Body lives on the exchange; skip response.Body stamp when unused by callers.
return new Http2OriginExchange(response, Array.Empty(), pending.TrailingHeaders);
}
@@ -551,8 +551,7 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream:
{
var body = pending.TakeInlineBody();
response.IsBodyRead = true;
- response.Body = body;
- response.BodyIsWireEncoded = true;
+ // Keep exchange.Body; skip response.Body — deliver paths use exchange.Body / wire emit.
if (trailers != null)
{
foreach (var header in trailers)
@@ -567,7 +566,6 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream:
if (pending.IsInboundComplete && pending.BodyPipeOrNull == null)
{
response.IsBodyRead = true;
- response.Body = Array.Empty();
return new Http2OriginExchange(response, Array.Empty(), trailers);
}
@@ -599,9 +597,7 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream:
}
response.IsBodyRead = true;
- response.Body = body;
- // H2 DATA payload is already content-encoded when Content-Encoding is present.
- response.BodyIsWireEncoded = true;
+ // Keep exchange.Body; skip response.Body — deliver paths use exchange.Body / wire emit.
if (trailers != null)
{
foreach (var header in trailers)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs
index 5d475808a..d719fad71 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs
@@ -116,6 +116,7 @@ public void ReleaseStream(int clientStreamId)
assignment.Leg.OriginToClient.TryRemove(assignment.OriginStreamId, out _);
assignment.Leg.SendFlow.RemoveStream(assignment.OriginStreamId);
+ assignment.Leg.OutboundDeferred.CancelStream(assignment.OriginStreamId);
lock (assignment.Leg.IdLock)
{
if (assignment.Leg.ActiveStreams > 0)
@@ -264,6 +265,7 @@ public OriginLeg(TcpServerConnection connection, Http2FrameWriter writer, Semaph
OwnsWriteLock = ownsWriteLock;
NextStreamId = 1;
SendFlow = new Http2FlowController();
+ OutboundDeferred = new Http2DeferredOutboundData();
}
public TcpServerConnection Connection { get; }
@@ -276,6 +278,8 @@ public OriginLeg(TcpServerConnection connection, Http2FrameWriter writer, Semaph
public int ActiveStreams;
public ConcurrentDictionary OriginToClient { get; } = new();
public Http2FlowController SendFlow { get; }
+ /// DATA waiting on so the client frame loop never parks.
+ public Http2DeferredOutboundData OutboundDeferred { get; }
public Http2Settings Settings { get; } = new();
public int InitialWindowUpdateSent;
}
diff --git a/src/Titanium.Web.Proxy/Http3/H3H1QpackResponseReader.cs b/src/Titanium.Web.Proxy/Http3/H3H1QpackResponseReader.cs
index c952f682d..6610fa039 100644
--- a/src/Titanium.Web.Proxy/Http3/H3H1QpackResponseReader.cs
+++ b/src/Titanium.Web.Proxy/Http3/H3H1QpackResponseReader.cs
@@ -60,6 +60,17 @@ internal readonly struct Result
alsoPopulate);
}
+ // Prefer byte-buffer fill + TryConsume on the hot path. When the buffer is full without
+ // an LF (or EOF leaves a partial line), fall back to ReadLine spanning (cold path).
+ var fill = await reader.FillBufferWithResultAsync(cancellationToken);
+ if (fill == BufferFillResult.Cancelled)
+ return null;
+ if (fill == BufferFillResult.GotData)
+ continue;
+
+ if (reader.Available == 0)
+ return Finish(builder, contentLength, isChunked, connectionClose);
+
var (tmpLine, cancelled) = await reader.ReadLineWithResultAsync(cancellationToken);
if (cancelled)
return null;
diff --git a/src/Titanium.Web.Proxy/Http3/Http3Frame.cs b/src/Titanium.Web.Proxy/Http3/Http3Frame.cs
index ced3ef0ad..d7c926ff9 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3Frame.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3Frame.cs
@@ -92,7 +92,7 @@ public void ReturnPayload()
/// , STREAM data and FIN share one MsQuic write. Callers must still
/// FlushAsync (Darwin skip-Flush is banned).
///
- public static async ValueTask WriteAsync(
+ public static ValueTask WriteAsync(
Stream stream,
ulong frameType,
ReadOnlyMemory payload,
@@ -104,32 +104,42 @@ public static async ValueTask WriteAsync(
if (payload.Length <= 256)
{
var rented = ArrayPool.Shared.Rent(headerCap + payload.Length);
- try
- {
- var typeLen = Http3VarInt.Write(rented, frameType);
- var lengthLen = Http3VarInt.Write(rented.AsSpan(typeLen), (ulong)payload.Length);
- var headerLen = typeLen + lengthLen;
- if (!payload.IsEmpty)
- payload.Span.CopyTo(rented.AsSpan(headerLen));
- await WriteBufferAsync(stream, rented.AsMemory(0, headerLen + payload.Length),
- completeWrites, cancellationToken);
- }
- finally
- {
- ArrayPool.Shared.Return(rented);
- }
-
- return;
+ var typeLen = Http3VarInt.Write(rented, frameType);
+ var lengthLen = Http3VarInt.Write(rented.AsSpan(typeLen), (ulong)payload.Length);
+ var headerLen = typeLen + lengthLen;
+ if (!payload.IsEmpty)
+ payload.Span.CopyTo(rented.AsSpan(headerLen));
+ var total = headerLen + payload.Length;
+ // Always await before ArrayPool.Return. Returning on IsCompletedSuccessfully without
+ // consuming the ValueTask let QuicStream/MsQuic keep the Memory while the next frame
+ // Rent reused the same array — H3_FRAME_ERROR on streamed H3 bodies (≥16 KiB chunks /
+ // H3→H2 above the 8 KiB origin buffer). Introduced by e781b009 ValueTask fast-path.
+ return AwaitWriteAndReturnAsync(
+ WriteBufferAsync(stream, rented.AsMemory(0, total), completeWrites, cancellationToken),
+ rented);
}
// Large DATA: one write for the header, then the payload buffer as-is (avoid a huge copy).
+ return WriteLargeAsync(stream, frameType, payload, completeWrites, cancellationToken);
+ }
+
+ private static async ValueTask WriteLargeAsync(
+ Stream stream,
+ ulong frameType,
+ ReadOnlyMemory payload,
+ bool completeWrites,
+ CancellationToken cancellationToken)
+ {
+ const int headerCap = 16;
var headerBytes = ArrayPool.Shared.Rent(headerCap);
try
{
var typeLen = Http3VarInt.Write(headerBytes, frameType);
var lengthLen = Http3VarInt.Write(headerBytes.AsSpan(typeLen), (ulong)payload.Length);
- await stream.WriteAsync(headerBytes.AsMemory(0, typeLen + lengthLen), cancellationToken);
- await WriteBufferAsync(stream, payload, completeWrites, cancellationToken);
+ await stream.WriteAsync(headerBytes.AsMemory(0, typeLen + lengthLen), cancellationToken)
+ .ConfigureAwait(false);
+ await WriteBufferAsync(stream, payload, completeWrites, cancellationToken)
+ .ConfigureAwait(false);
}
finally
{
@@ -137,14 +147,26 @@ await WriteBufferAsync(stream, rented.AsMemory(0, headerLen + payload.Length),
}
}
+ private static async ValueTask AwaitWriteAndReturnAsync(ValueTask writeVt, byte[] rented)
+ {
+ try
+ {
+ await writeVt.ConfigureAwait(false);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(rented);
+ }
+ }
+
///
/// Writes a zero-payload frame (used for GOAWAY and some SETTINGS without parameters).
///
- public static async ValueTask WriteAsync(
+ public static ValueTask WriteAsync(
Stream stream,
ulong frameType,
CancellationToken cancellationToken)
- => await WriteAsync(stream, frameType, ReadOnlyMemory.Empty, cancellationToken);
+ => WriteAsync(stream, frameType, ReadOnlyMemory.Empty, cancellationToken);
///
/// Writes HEADERS then DATA as a single stream write when the combined frames fit a modest
@@ -152,7 +174,7 @@ public static async ValueTask WriteAsync(
/// UDP path does not emit a header-only datagram before the body. Tiny GET stays on the
/// separate-write path (HEADERS+DATA coalesce there hurt CI).
///
- public static async ValueTask WriteHeadersAndDataAsync(
+ public static ValueTask WriteHeadersAndDataAsync(
Stream stream,
ReadOnlyMemory headersPayload,
ReadOnlyMemory dataPayload,
@@ -162,31 +184,27 @@ public static async ValueTask WriteHeadersAndDataAsync(
const int headerCap = 16;
var total = headerCap + headersPayload.Length + headerCap + dataPayload.Length;
var rented = ArrayPool.Shared.Rent(total);
- try
+ var o = 0;
+ o += Http3VarInt.Write(rented.AsSpan(o), Http3FrameType.Headers);
+ o += Http3VarInt.Write(rented.AsSpan(o), (ulong)headersPayload.Length);
+ if (!headersPayload.IsEmpty)
{
- var o = 0;
- o += Http3VarInt.Write(rented.AsSpan(o), Http3FrameType.Headers);
- o += Http3VarInt.Write(rented.AsSpan(o), (ulong)headersPayload.Length);
- if (!headersPayload.IsEmpty)
- {
- headersPayload.Span.CopyTo(rented.AsSpan(o));
- o += headersPayload.Length;
- }
-
- o += Http3VarInt.Write(rented.AsSpan(o), Http3FrameType.Data);
- o += Http3VarInt.Write(rented.AsSpan(o), (ulong)dataPayload.Length);
- if (!dataPayload.IsEmpty)
- {
- dataPayload.Span.CopyTo(rented.AsSpan(o));
- o += dataPayload.Length;
- }
-
- await WriteBufferAsync(stream, rented.AsMemory(0, o), completeWrites, cancellationToken);
+ headersPayload.Span.CopyTo(rented.AsSpan(o));
+ o += headersPayload.Length;
}
- finally
+
+ o += Http3VarInt.Write(rented.AsSpan(o), Http3FrameType.Data);
+ o += Http3VarInt.Write(rented.AsSpan(o), (ulong)dataPayload.Length);
+ if (!dataPayload.IsEmpty)
{
- ArrayPool.Shared.Return(rented);
+ dataPayload.Span.CopyTo(rented.AsSpan(o));
+ o += dataPayload.Length;
}
+
+ // Same ValueTask-consume-before-Return rule as WriteAsync (see comment there).
+ return AwaitWriteAndReturnAsync(
+ WriteBufferAsync(stream, rented.AsMemory(0, o), completeWrites, cancellationToken),
+ rented);
}
#pragma warning disable CA1416 // QuicStream.WriteAsync(completeWrites) is gated on the runtime stream type.
diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Http2.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Http2.cs
index 0fad1c336..038327ac6 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Http2.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Http2.cs
@@ -104,10 +104,24 @@ internal static async Task ForwardOverHttp2FastAsync(
if (response.StreamBodyWriter == null)
{
response.IsBodyRead = true;
- response.Body = exchange.Body;
- // Http2OriginConnection materializes H2 DATA wire bytes.
- response.BodyIsWireEncoded = true;
+ response.ContentLength = exchange.Body.Length;
+ // Interception-off: skip response.Body — emit via Preencoded HEADERS+DATA.
+ if (fwd.PreencodedQpackHeaders == null)
+ fwd.PreencodedQpackHeaders = QpackEncoder.EncodeResponse(response, context: null);
+ fwd.PreencodedBody = exchange.Body;
+ fwd.PreencodedBodyLength = exchange.Body.Length;
+ // MITM unchanged-lite seeds Response before the call. Stamp Body onto the
+ // exchange Response (which replaces that seed) so BeforeResponse /
+ // FinishMitm fallback can read it — match Tcp ForwardOverTcpFastAsync.
+ // Reverse (Response null) keeps skip-Body coalesce.
+ if (fwd.Response != null)
+ {
+ response.Body = exchange.Body;
+ response.BodyIsWireEncoded = true;
+ response.IsBodyReceived = true;
+ }
}
+ // else: leave StreamBodyWriter + IsBodyRead alone so SendResponseAsync streams DATA.
if (exchange.TrailingHeaders != null && !response.HasTrailingHeaders)
{
diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs
index 0d19df04a..904d0a94a 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs
@@ -828,10 +828,10 @@ private static void CaptureMitmQuicResponse(H3H2FastForward fwd, ReadOnlyMemory<
fwd.PreencodedBodyRented = false;
if (fwd.Response != null)
{
- fwd.Response.Body = Array.Empty();
- fwd.Response.BodyIsWireEncoded = true;
+ // Skip Body stamp — emit uses Preencoded; headers-only for BeforeResponse.
fwd.Response.IsBodyReceived = true;
fwd.Response.IsBodyRead = true;
+ fwd.Response.ContentLength = 0;
}
return;
@@ -843,12 +843,10 @@ private static void CaptureMitmQuicResponse(H3H2FastForward fwd, ReadOnlyMemory<
fwd.PreencodedBodyRented = false;
if (fwd.Response != null)
{
- var copy = new byte[body.Length];
- Buffer.BlockCopy(body, 0, copy, 0, body.Length);
- fwd.Response.Body = copy;
- fwd.Response.BodyIsWireEncoded = true;
+ // Skip dual Body copy — PreencodedBody is the wire buffer for SendPreencoded.
fwd.Response.IsBodyReceived = true;
fwd.Response.IsBodyRead = true;
+ fwd.Response.ContentLength = body.Length;
}
}
@@ -870,12 +868,9 @@ private static void AppendMitmQuicBody(H3H2FastForward fwd, ReadOnlyMemory
fwd.PreencodedBodyRented = false;
if (fwd.Response != null)
{
- var copy = new byte[combined.Length];
- Buffer.BlockCopy(combined, 0, copy, 0, combined.Length);
- fwd.Response.Body = copy;
- fwd.Response.BodyIsWireEncoded = true;
fwd.Response.IsBodyReceived = true;
fwd.Response.IsBodyRead = true;
+ fwd.Response.ContentLength = combined.Length;
}
}
diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Tcp.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Tcp.cs
index a94447d2b..1927fb6d6 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Tcp.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Tcp.cs
@@ -541,9 +541,9 @@ await sessionArgs.HttpClient.SendRequest(
var earlyPump = earlyBodyPump;
var bodyPump = sessionArgs.Http3RequestBodyPump;
var trailing = request.HasTrailingHeaders ? request.TrailingHeaders : null;
- uploadTask = PumpUploadAsync();
+ uploadTask = PumpUploadAsync().AsTask();
- async Task PumpUploadAsync()
+ async ValueTask PumpUploadAsync()
{
try
{
@@ -561,10 +561,10 @@ async Task PumpUploadAsync()
else if (bodyPump != null)
{
await bodyPump(
- async (data, ct) =>
+ (data, ct) =>
{
- if (!data.IsEmpty)
- await bodyWriter.WriteAsync(data, ct);
+ if (data.IsEmpty) return default;
+ return bodyWriter.WriteAsync(data, ct);
},
cancellationToken);
}
@@ -669,7 +669,7 @@ await connection.Stream.WriteBodyAsync(body ?? Array.Empty(), request.IsCh
response.StreamBodyWriter = async (clientBodyStream, ct) =>
{
- async Task CopyResponseAsync()
+ async ValueTask CopyResponseAsync()
{
IHttpStreamReader reader = originConnection.Stream;
using var limited = new LimitedStream(reader, server.BufferPool, originIsChunked,
@@ -689,11 +689,11 @@ async Task CopyResponseAsync()
}
// Keep request upload live while copying the response (true duplex).
- var copyTask = CopyResponseAsync();
+ var copyVt = CopyResponseAsync();
if (pendingUpload != null)
- await Task.WhenAll(pendingUpload, copyTask);
+ await Task.WhenAll(pendingUpload, copyVt.AsTask());
else
- await copyTask;
+ await copyVt;
};
}
else if (uploadTask != null)
diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
index dc3161d8f..db59052a7 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
@@ -289,8 +289,13 @@ await HandleH3OriginFastPathAsync(
var mitmUnchangedH3H3 = !mitmUnchangedH3H1 && TryMitmUnchangedH3ToH3Lite(
sessionArgs, authArgs, request, requestHeaderRelayBaseline,
capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method);
+ var mitmUnchangedH3H2 = !mitmUnchangedH3H1 && !mitmUnchangedH3H3
+ && TryMitmUnchangedH3ToH2Lite(
+ sessionArgs, authArgs, request, requestHeaderRelayBaseline,
+ capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method);
- if (!mitmUnchangedH3H1 && !mitmUnchangedH3H3 && !sessionArgs.IsFastPath
+ if (!mitmUnchangedH3H1 && !mitmUnchangedH3H3 && !mitmUnchangedH3H2
+ && !sessionArgs.IsFastPath
&& !string.IsNullOrEmpty(server.ViaHeaderPseudonym))
sessionArgs.HttpClient.Request.Headers.AddHeader(
new HttpHeader("via", $"3.0 {server.ViaHeaderPseudonym}"));
@@ -313,11 +318,12 @@ await HandleH3OriginFastPathAsync(
new HttpHeader("via", $"3.0 {server.ViaHeaderPseudonym}"));
await SendResponseAsync(stream, sessionArgs.HttpClient.Response, qpackContext, cancellationToken);
}
- else if (mitmUnchangedH3H1 || mitmUnchangedH3H3)
+ else if (mitmUnchangedH3H1 || mitmUnchangedH3H3 || mitmUnchangedH3H2)
{
- // True MITM noop-safe: H3→H1 uses ForwardOverTcpFastAsync; H3→H3 captures QPACK
- // via ForwardOverQuicFastAsync(clientStream: null) then SendPreencoded after
- // BeforeResponse (same shape as H3→H1 lite — never emit before the handler).
+ // True MITM noop-safe: H3→H1 uses ForwardOverTcpFastAsync; H3→H2 uses
+ // ForwardOverHttp2FastAsync; H3→H3 captures QPACK via
+ // ForwardOverQuicFastAsync(clientStream: null). All three finish with
+ // SendPreencoded after BeforeResponse — never emit before the handler.
if (!streamState.RequestClosed)
{
sessionArgs.Http3BufferedBodyReader = null;
@@ -362,7 +368,9 @@ SessionEventArgs ColdOpenSessionFactory()
stub.CustomUpStreamProxy = fwd.CustomUpStreamProxy;
stub.UpstreamHttpProtocol = mitmUnchangedH3H3
? UpstreamHttpProtocol.Http3
- : UpstreamHttpProtocol.Http11;
+ : mitmUnchangedH3H2
+ ? UpstreamHttpProtocol.Http2
+ : UpstreamHttpProtocol.Http11;
return stub;
}
@@ -386,6 +394,20 @@ await FinishMitmPreencodedResponseAsync(sessionArgs, fwd, stream, server,
onBeforeResponse, qpackContext, cancellationToken);
}
}
+ else if (mitmUnchangedH3H2)
+ {
+ await Http3OriginBridge.ForwardOverHttp2FastAsync(fwd, server, logger,
+ cancellationToken, ColdOpenSessionFactory);
+
+ // ForwardOverHttp2FastAsync assigns a new Response onto fwd (unlike
+ // Tcp/Quic in-place populate). Point the session bag at it before
+ // BeforeResponse / static QPACK finish.
+ sessionArgs.HttpClient.Response = fwd.Response
+ ?? sessionArgs.HttpClient.Response;
+
+ await FinishMitmPreencodedResponseAsync(sessionArgs, fwd, stream, server,
+ onBeforeResponse, qpackContext, cancellationToken);
+ }
else
{
await Http3OriginBridge.ForwardOverTcpFastAsync(fwd, server, logger, cancellationToken,
@@ -744,6 +766,30 @@ private static bool TryMitmUnchangedH3ToH3Lite( // NOSONAR S107 -- Baseline capt
capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method);
}
+ ///
+ /// True MITM H3→H2: after BeforeRequest left the exchange unchanged, reuse reverse's
+ /// then
+ /// after BeforeResponse.
+ ///
+ private static bool TryMitmUnchangedH3ToH2Lite( // NOSONAR S107 -- Baseline capture args kept explicit to avoid allocating context structs on the H3 MITM hot path.
+ SessionEventArgs sessionArgs,
+ BeforeQuicAuthenticateEventArgs authArgs,
+ Request request,
+ MitmCompressedRelayHelper.HeaderRelayBaseline requestHeaderRelayBaseline,
+ string? capturedRequestMethod,
+ ByteString capturedRequestPath,
+ ByteString capturedRequestAuthority,
+ string method)
+ {
+ if (sessionArgs.IsFastPath)
+ return false;
+ if (authArgs.UpstreamHttpProtocol != UpstreamHttpProtocol.Http2)
+ return false;
+ return MitmUnchangedLiteRequestMatches(
+ sessionArgs, request, requestHeaderRelayBaseline,
+ capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method);
+ }
+
private static bool MitmUnchangedLiteRequestMatches( // NOSONAR S107
SessionEventArgs sessionArgs,
Request request,
@@ -775,8 +821,8 @@ private static bool MitmUnchangedLiteRequestMatches( // NOSONAR S107
}
///
- /// After H3→H1 / H3→H3 MITM lite origin fetch: BeforeResponse, then static QPACK relay or
- /// full re-encode. Never emits before the response handler (noop-safe).
+ /// After H3→H1 / H3→H2 / H3→H3 MITM lite origin fetch: BeforeResponse, then static QPACK
+ /// relay or full re-encode. Never emits before the response handler (noop-safe).
///
private static async Task FinishMitmPreencodedResponseAsync( // NOSONAR S3776 -- MITM lite emit stays one method; splitting adds await/state-machine risk.
SessionEventArgs sessionArgs,
@@ -855,7 +901,39 @@ await SendPreencodedResponseAsync(stream, qpackHeaders,
if (injectVia)
response.Headers.AddHeader(
new HttpHeader("via", $"3.0 {server.ViaHeaderPseudonym}"));
- if (fwd.PreencodedBodyRented && fwd.PreencodedBody != null)
+
+ // H2 MITM stamps Body in ForwardOverHttp2FastAsync; H1 Tcp copies onto the seed.
+ // H3 Quic capture may leave Body null with payload only in PreencodedBody — materialize
+ // before SendResponseAsync so Content-Length is not emitted without DATA.
+ if (!response.BodyAvailable && fwd.PreencodedBody != null)
+ {
+ var len = fwd.PreencodedBodyLength > 0
+ ? fwd.PreencodedBodyLength
+ : fwd.PreencodedBody.Length;
+ if (len <= 0)
+ {
+ response.Body = [];
+ }
+ else if (!fwd.PreencodedBodyRented && fwd.PreencodedBody.Length == len)
+ {
+ response.Body = fwd.PreencodedBody;
+ }
+ else
+ {
+ var copy = new byte[len];
+ Buffer.BlockCopy(fwd.PreencodedBody, 0, copy, 0, len);
+ response.Body = copy;
+ if (fwd.PreencodedBodyRented)
+ {
+ server.BufferPool.ReturnBuffer(fwd.PreencodedBody);
+ fwd.PreencodedBodyRented = false;
+ }
+ }
+
+ response.BodyIsWireEncoded = true;
+ fwd.PreencodedBody = null;
+ }
+ else if (fwd.PreencodedBodyRented && fwd.PreencodedBody != null)
{
// Response.Body holds an owned copy; return the rented Preencoded buffer.
server.BufferPool.ReturnBuffer(fwd.PreencodedBody);
@@ -1104,7 +1182,7 @@ private static async Task SendInterimResponseAsync(
///
/// Writes a pre-encoded QPACK HEADERS block (+ optional DATA) from the H3→H1 one-pass path.
///
- private static async Task SendPreencodedResponseAsync(
+ private static async ValueTask SendPreencodedResponseAsync(
QuicStream stream,
byte[] qpackHeaders,
ReadOnlyMemory body,
@@ -1296,11 +1374,11 @@ public override void Write(byte[] buffer, int offset, int count) =>
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
- public override async ValueTask WriteAsync(ReadOnlyMemory buffer,
+ public override ValueTask WriteAsync(ReadOnlyMemory buffer,
CancellationToken cancellationToken = default)
{
- if (buffer.IsEmpty) return;
- await Http3Frame.WriteAsync(_stream, Http3FrameType.Data, buffer, cancellationToken);
+ if (buffer.IsEmpty) return default;
+ return Http3Frame.WriteAsync(_stream, Http3FrameType.Data, buffer, cancellationToken);
}
}
diff --git a/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs b/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs
index 93db3859a..654312689 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3VarInt.cs
@@ -126,25 +126,85 @@ public static bool TryRead(ReadOnlySpan source, out ulong value, out int b
/// Reads a variable-length integer from a (including ).
/// Returns when the stream ends before a complete integer arrives.
///
- public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken)
+ public static ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken)
{
// RFC 9000 VarInts are at most 8 bytes. One small buffer replaces the previous
// per-call new byte[1] + new byte[1+remaining] pair (two VarInts per H3 frame).
var buf = ArrayPool.Shared.Rent(8);
+ var firstVt = ReadExactAsync(stream, buf.AsMemory(0, 1), cancellationToken);
+ if (!firstVt.IsCompletedSuccessfully)
+ return ReadAsyncSlow(stream, buf, firstVt, firstDone: false, cancellationToken);
+
+ if (!firstVt.Result)
+ {
+ ArrayPool.Shared.Return(buf);
+ return new ValueTask((ulong?)null);
+ }
+
+ var prefix = (buf[0] & 0xC0) >> 6;
+ int remaining = prefix switch
+ {
+ 0 => 0,
+ 1 => 1,
+ 2 => 3,
+ _ => 7
+ };
+
+ if (remaining == 0)
+ {
+ TryRead(buf.AsSpan(0, 1), out var value, out _);
+ ArrayPool.Shared.Return(buf);
+ return new ValueTask(value);
+ }
+
+ var restVt = ReadExactAsync(stream, buf.AsMemory(1, remaining), cancellationToken);
+ if (!restVt.IsCompletedSuccessfully)
+ return ReadAsyncSlow(stream, buf, restVt, firstDone: true, cancellationToken, remaining);
+
+ if (!restVt.Result)
+ {
+ ArrayPool.Shared.Return(buf);
+ return new ValueTask((ulong?)null);
+ }
+
+ TryRead(buf.AsSpan(0, 1 + remaining), out var full, out _);
+ ArrayPool.Shared.Return(buf);
+ return new ValueTask(full);
+ }
+
+ private static async ValueTask ReadAsyncSlow(
+ Stream stream,
+ byte[] buf,
+ ValueTask pending,
+ bool firstDone,
+ CancellationToken cancellationToken,
+ int remainingHint = -1)
+ {
try
{
- if (!await ReadExactAsync(stream, buf.AsMemory(0, 1), cancellationToken)) return null;
+ if (!await pending.ConfigureAwait(false))
+ return null;
- var prefix = (buf[0] & 0xC0) >> 6;
- int remaining = prefix switch
+ int remaining;
+ if (!firstDone)
{
- 0 => 0,
- 1 => 1,
- 2 => 3,
- _ => 7
- };
+ var prefix = (buf[0] & 0xC0) >> 6;
+ remaining = prefix switch
+ {
+ 0 => 0,
+ 1 => 1,
+ 2 => 3,
+ _ => 7
+ };
+ }
+ else
+ {
+ remaining = remainingHint;
+ }
- if (remaining > 0 && !await ReadExactAsync(stream, buf.AsMemory(1, remaining), cancellationToken))
+ if (!firstDone && remaining > 0
+ && !await ReadExactAsync(stream, buf.AsMemory(1, remaining), cancellationToken)
+ .ConfigureAwait(false))
return null;
TryRead(buf.AsSpan(0, 1 + remaining), out var value, out _);
@@ -156,15 +216,44 @@ public static bool TryRead(ReadOnlySpan source, out ulong value, out int b
}
}
- private static async ValueTask ReadExactAsync(Stream stream, Memory buffer, CancellationToken ct)
+ private static ValueTask ReadExactAsync(Stream stream, Memory buffer, CancellationToken ct)
+ {
+ if (buffer.Length == 0)
+ return new ValueTask(true);
+
+ var readVt = stream.ReadAsync(buffer, ct);
+ if (!readVt.IsCompletedSuccessfully)
+ return ReadExactSlowAsync(stream, buffer, readVt, ct);
+
+ var read = readVt.Result;
+ if (read == 0)
+ return new ValueTask(false);
+ if (read == buffer.Length)
+ return new ValueTask(true);
+
+ return ReadExactContinueAsync(stream, buffer.Slice(read), ct);
+ }
+
+ private static async ValueTask ReadExactSlowAsync(
+ Stream stream, Memory buffer, ValueTask firstRead, CancellationToken ct)
+ {
+ var read = await firstRead.ConfigureAwait(false);
+ if (read == 0) return false;
+ if (read == buffer.Length) return true;
+ return await ReadExactContinueAsync(stream, buffer.Slice(read), ct).ConfigureAwait(false);
+ }
+
+ private static async ValueTask ReadExactContinueAsync(
+ Stream stream, Memory buffer, CancellationToken ct)
{
var offset = 0;
while (offset < buffer.Length)
{
- var read = await stream.ReadAsync(buffer.Slice(offset), ct);
+ var read = await stream.ReadAsync(buffer.Slice(offset), ct).ConfigureAwait(false);
if (read == 0) return false;
offset += read;
}
+
return true;
}
}
diff --git a/src/Titanium.Web.Proxy/Network/HttpWebClient.cs b/src/Titanium.Web.Proxy/Network/HttpWebClient.cs
index cd8e8a749..45d75bb7f 100644
--- a/src/Titanium.Web.Proxy/Network/HttpWebClient.cs
+++ b/src/Titanium.Web.Proxy/Network/HttpWebClient.cs
@@ -6,6 +6,7 @@
using Titanium.Web.Proxy.Diagnostics;
using Titanium.Web.Proxy.Exceptions;
using Titanium.Web.Proxy.Extensions;
+using Titanium.Web.Proxy.Helpers;
using Titanium.Web.Proxy.Models;
using Titanium.Web.Proxy.Network.Quic;
using Titanium.Web.Proxy.Network.Tcp;
@@ -229,7 +230,7 @@ internal static Version ResolveOriginHttpVersion(Version requestHttpVersion,
/// Prepare and send the http(s) request
///
///
- internal async Task SendRequest(bool enable100ContinueBehaviour, bool isTransparent, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
+ internal ValueTask SendRequest(bool enable100ContinueBehaviour, bool isTransparent, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
OriginHttpVersionPolicy originHttpVersionPolicy, CancellationToken cancellationToken)
{
var upstreamProxy = Connection.UpStreamProxy;
@@ -294,6 +295,7 @@ internal static Version ResolveOriginHttpVersion(Version requestHttpVersion,
// prepare the request & headers
var headerBuilder = HeaderBuilder.Rent();
+ ValueTask writeVt;
try
{
if (url != null)
@@ -308,38 +310,74 @@ internal static Version ResolveOriginHttpVersion(Version requestHttpVersion,
headerBuilder.WriteHeaders(Request.Headers, !isTransparent, upstreamProxyUserName,
upstreamProxyPassword, Request.UpstreamCleartextHostOverride);
- // write request headers
- await serverStream.WriteHeadersAsync(headerBuilder, cancellationToken);
+ writeVt = serverStream.WriteHeadersAsync(headerBuilder, cancellationToken);
}
- finally
+ catch
{
HeaderBuilder.Return(headerBuilder);
+ throw;
}
- if (enable100ContinueBehaviour && Request.ExpectContinue)
+ if (writeVt.IsCompletedSuccessfully)
{
- // wait for expectation response from server
- await ReceiveResponse(cancellationToken);
+ HeaderBuilder.Return(headerBuilder);
+ if (enable100ContinueBehaviour && Request.ExpectContinue)
+ return SendRequestExpectContinueAsync(cancellationToken);
- if (Response.StatusCode == (int)HttpStatusCode.Continue)
- Request.ExpectationSucceeded = true;
- else
- Request.ExpectationFailed = true;
+ return default;
}
+
+ return SendRequestAwaitWriteAsync(writeVt, headerBuilder, enable100ContinueBehaviour,
+ cancellationToken);
+ }
+
+ private async ValueTask SendRequestAwaitWriteAsync(ValueTask writeVt, HeaderBuilder headerBuilder,
+ bool enable100ContinueBehaviour, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await writeVt;
+ }
+ finally
+ {
+ HeaderBuilder.Return(headerBuilder);
+ }
+
+ if (enable100ContinueBehaviour && Request.ExpectContinue)
+ await SendRequestExpectContinueAsync(cancellationToken);
+ }
+
+ private async ValueTask SendRequestExpectContinueAsync(CancellationToken cancellationToken)
+ {
+ await ReceiveResponse(cancellationToken);
+
+ if (Response.StatusCode == (int)HttpStatusCode.Continue)
+ Request.ExpectationSucceeded = true;
+ else
+ Request.ExpectationFailed = true;
}
///
/// Receive and parse the http response from server
///
///
- internal async Task ReceiveResponse(CancellationToken cancellationToken)
+ internal ValueTask ReceiveResponse(CancellationToken cancellationToken)
{
// return if this is already read
- if (Response.StatusCode != 0) return;
+ if (Response.StatusCode != 0) return default;
Response.RequestMethod = Request.Method;
- var httpStatus = await Connection.Stream.ReadResponseStatus(cancellationToken);
+ var statusVt = Connection.Stream.ReadResponseStatus(cancellationToken);
+ if (!statusVt.IsCompletedSuccessfully)
+ return ReceiveResponseSlowAsync(statusVt, cancellationToken);
+
+ return ReceiveResponseAfterStatus(statusVt.Result, cancellationToken);
+ }
+
+ private ValueTask ReceiveResponseAfterStatus(ResponseStatusInfo? httpStatus,
+ CancellationToken cancellationToken)
+ {
if (httpStatus == null)
{
// EOF before any response bytes: typically a stale pooled keep-alive connection.
@@ -356,8 +394,29 @@ internal async Task ReceiveResponse(CancellationToken cancellationToken)
Response.StatusCode = httpStatus.Value.StatusCode;
Response.StatusDescription = httpStatus.Value.Description;
- // Read the response headers in to unique and non-unique header collections
- await HeaderParser.ReadHeaders(Connection.Stream, Response.Headers, cancellationToken);
+ var headersVt = HeaderParser.TryReadHeadersAsync(Connection.Stream, Response.Headers, cancellationToken);
+ if (headersVt.IsCompletedSuccessfully)
+ {
+ if (!headersVt.Result)
+ cancellationToken.ThrowIfCancellationRequested();
+ return default;
+ }
+
+ return ReceiveResponseAwaitHeadersAsync(headersVt, cancellationToken);
+ }
+
+ private async ValueTask ReceiveResponseSlowAsync(ValueTask statusVt,
+ CancellationToken cancellationToken)
+ {
+ var httpStatus = await statusVt;
+ await ReceiveResponseAfterStatus(httpStatus, cancellationToken);
+ }
+
+ private static async ValueTask ReceiveResponseAwaitHeadersAsync(ValueTask headersVt,
+ CancellationToken cancellationToken)
+ {
+ if (!await headersVt)
+ cancellationToken.ThrowIfCancellationRequested();
}
///
diff --git a/src/Titanium.Web.Proxy/Network/Readers/IHttpStreamReader.cs b/src/Titanium.Web.Proxy/Network/Readers/IHttpStreamReader.cs
index 306c2a20d..017c4d8a9 100644
--- a/src/Titanium.Web.Proxy/Network/Readers/IHttpStreamReader.cs
+++ b/src/Titanium.Web.Proxy/Network/Readers/IHttpStreamReader.cs
@@ -10,6 +10,6 @@ public interface IHttpStreamReader : ILineStream
Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken);
- Task CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength,
+ ValueTask CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength,
bool isRequest, SessionEventArgs args, CancellationToken cancellationToken);
}
\ No newline at end of file
diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpClientStream.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpClientStream.cs
index a8e2cb657..faea38423 100644
--- a/src/Titanium.Web.Proxy/Network/Streams/HttpClientStream.cs
+++ b/src/Titanium.Web.Proxy/Network/Streams/HttpClientStream.cs
@@ -25,7 +25,7 @@ internal HttpClientStream(ProxyServer server, TcpClientConnection connection, St
/// The response object.
/// Optional cancellation token for this async task.
/// The Task.
- internal async ValueTask WriteResponseAsync(Response response, CancellationToken cancellationToken = default)
+ internal ValueTask WriteResponseAsync(Response response, CancellationToken cancellationToken = default)
{
var headerBuilder = HeaderBuilder.Rent();
try
@@ -42,7 +42,75 @@ internal async ValueTask WriteResponseAsync(Response response, CancellationToken
response.Headers.RemoveHeader(KnownHeaders.TransferEncoding);
}
- await WriteAsync(response, headerBuilder, cancellationToken);
+ var writeVt = WriteAsync(response, headerBuilder, cancellationToken);
+ if (writeVt.IsCompletedSuccessfully)
+ {
+ HeaderBuilder.Return(headerBuilder);
+ return default;
+ }
+
+ return AwaitAndReturnHeaderBuilder(writeVt, headerBuilder);
+ }
+ catch
+ {
+ HeaderBuilder.Return(headerBuilder);
+ throw;
+ }
+ }
+
+ ///
+ /// Writes response headers plus an already-materialized wire body in one coalesced write
+ /// without assigning (skips CompressBody).
+ ///
+ internal ValueTask WriteResponseWithWireBodyAsync(Response response, ReadOnlyMemory wireBody,
+ CancellationToken cancellationToken = default)
+ {
+ var headerBuilder = HeaderBuilder.Rent();
+ try
+ {
+ headerBuilder.WriteResponseLine(response.HttpVersion, response.StatusCode, response.StatusDescription);
+ headerBuilder.WriteHeaders(response.Headers);
+ if (!wireBody.IsEmpty)
+ headerBuilder.WriteRaw(wireBody.Span);
+
+ var writeVt = WriteHeadersAsync(headerBuilder, cancellationToken);
+ if (writeVt.IsCompletedSuccessfully)
+ {
+ response.IsBodySent = true;
+ response.IsBodyReceived = true;
+ HeaderBuilder.Return(headerBuilder);
+ return default;
+ }
+
+ return AwaitWireBodyWriteAsync(writeVt, headerBuilder, response);
+ }
+ catch
+ {
+ HeaderBuilder.Return(headerBuilder);
+ throw;
+ }
+ }
+
+ private static async ValueTask AwaitWireBodyWriteAsync(ValueTask writeVt, HeaderBuilder headerBuilder,
+ Response response)
+ {
+ try
+ {
+ await writeVt;
+ response.IsBodySent = true;
+ response.IsBodyReceived = true;
+ }
+ finally
+ {
+ HeaderBuilder.Return(headerBuilder);
+ }
+ }
+
+ private static async ValueTask AwaitAndReturnHeaderBuilder(ValueTask writeVt, HeaderBuilder headerBuilder)
+ {
+ try
+ {
+ await writeVt;
}
finally
{
diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpServerStream.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpServerStream.cs
index 9f68348bd..72c3b3607 100644
--- a/src/Titanium.Web.Proxy/Network/Streams/HttpServerStream.cs
+++ b/src/Titanium.Web.Proxy/Network/Streams/HttpServerStream.cs
@@ -22,17 +22,25 @@ internal HttpServerStream(ProxyServer server, Stream stream, IBufferPool bufferP
/// The request object.
/// Optional cancellation token for this async task.
///
- internal async ValueTask WriteRequestAsync(Request request, CancellationToken cancellationToken = default)
+ internal ValueTask WriteRequestAsync(Request request, CancellationToken cancellationToken = default)
{
var headerBuilder = HeaderBuilder.Rent();
try
{
headerBuilder.WriteRequestLine(request.Method, request.RequestUriString, request.HttpVersion);
- await WriteAsync(request, headerBuilder, cancellationToken);
+ var writeVt = WriteAsync(request, headerBuilder, cancellationToken);
+ if (writeVt.IsCompletedSuccessfully)
+ {
+ HeaderBuilder.Return(headerBuilder);
+ return default;
+ }
+
+ return AwaitAndReturnHeaderBuilder(writeVt, headerBuilder);
}
- finally
+ catch
{
HeaderBuilder.Return(headerBuilder);
+ throw;
}
}
@@ -43,22 +51,60 @@ internal async ValueTask WriteRequestAsync(Request request, CancellationToken ca
/// The parsed status info, or null when the peer closed the connection before sending
/// any status line (normal EOF / keep-alive idle close). Malformed status lines still throw.
///
- internal async ValueTask ReadResponseStatus(CancellationToken cancellationToken = default)
+ internal ValueTask ReadResponseStatus(CancellationToken cancellationToken = default)
{
- var httpStatus = await ReadLineAsync(cancellationToken);
- if (httpStatus == null)
- return null;
+ if (TryParseResponseLineFromBuffer(out var version, out var statusCode, out var description,
+ out var emptyLine))
+ {
+ if (!emptyLine)
+ {
+ return new ValueTask(new ResponseStatusInfo
+ {
+ Version = version, StatusCode = statusCode, Description = description
+ });
+ }
+
+ // Blank line before status — try again from the buffer, else fill.
+ if (TryParseResponseLineFromBuffer(out version, out statusCode, out description, out emptyLine)
+ && !emptyLine)
+ {
+ return new ValueTask(new ResponseStatusInfo
+ {
+ Version = version, StatusCode = statusCode, Description = description
+ });
+ }
+ }
- if (httpStatus.Length == 0)
+ return ReadResponseStatusFillAsync(cancellationToken);
+ }
+
+ private async ValueTask ReadResponseStatusFillAsync(CancellationToken cancellationToken)
+ {
+ while (true)
{
- // A blank line before the status is unusual; read again. A subsequent EOF is still a normal close,
- // not a protocol error.
- httpStatus = await ReadLineAsync(cancellationToken);
- if (httpStatus == null)
+ if (!await FillBufferAsync(cancellationToken))
return null;
+
+ if (!TryParseResponseLineFromBuffer(out var version, out var statusCode, out var description,
+ out var emptyLine))
+ continue;
+
+ if (emptyLine)
+ continue;
+
+ return new ResponseStatusInfo { Version = version, StatusCode = statusCode, Description = description };
}
+ }
- Response.ParseResponseLine(httpStatus, out var version, out var statusCode, out var description);
- return new ResponseStatusInfo { Version = version, StatusCode = statusCode, Description = description };
+ private static async ValueTask AwaitAndReturnHeaderBuilder(ValueTask writeVt, HeaderBuilder headerBuilder)
+ {
+ try
+ {
+ await writeVt;
+ }
+ finally
+ {
+ HeaderBuilder.Return(headerBuilder);
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Body.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Body.cs
index 2781a12b4..127150807 100644
--- a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Body.cs
+++ b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Body.cs
@@ -44,7 +44,7 @@ internal ValueTask WriteBodyAsync(byte[] data, bool isChunked, HeaderCollection?
return WriteAsync(data, cancellationToken: cancellationToken);
}
- public async Task CopyBodyAsync(RequestResponseBase requestResponse, bool useOriginalHeaderValues,
+ public async ValueTask CopyBodyAsync(RequestResponseBase requestResponse, bool useOriginalHeaderValues,
IHttpStreamWriter writer, TransformationMode transformation, bool isRequest, SessionEventArgs args,
CancellationToken cancellationToken)
{
@@ -106,7 +106,7 @@ public async Task CopyBodyAsync(RequestResponseBase requestResponse, bool useOri
///
///
///
- public Task CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength,
+ public ValueTask CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength,
bool isRequest,
SessionEventArgs args, CancellationToken cancellationToken)
{
@@ -124,7 +124,7 @@ public Task CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long content
((isRequest && args.HttpClient.Request.OriginalHasBody && !args.HttpClient.Request.IsBodyRead && server.ShouldCallBeforeRequestBodyWrite()) ||
(isResponse && args.HttpClient.Response.OriginalHasBody && !args.HttpClient.Response.IsBodyRead && server.ShouldCallBeforeResponseBodyWrite())))
{
- return HandleBodyWrite(writer, isChunked, isRequest, args, cancellationToken);
+ return new ValueTask(HandleBodyWrite(writer, isChunked, isRequest, args, cancellationToken));
}
// For chunked request we need to read data as they arrive, until we reach a chunk end symbol
@@ -364,7 +364,7 @@ private async ValueTask WriteBodyChunkedAsync(byte[] data, HeaderCollection? tra
///
///
///
- private async Task CopyBodyChunkedAsync(IHttpStreamWriter writer, bool isRequest, SessionEventArgs args,
+ private async ValueTask CopyBodyChunkedAsync(IHttpStreamWriter writer, bool isRequest, SessionEventArgs args,
CancellationToken cancellationToken)
{
var requestResponse = isRequest ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response;
@@ -412,7 +412,7 @@ await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHea
///
///
///
- private async Task CopyBytesToStream(IHttpStreamWriter writer, long count, bool isRequest, SessionEventArgs args, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
+ private async ValueTask CopyBytesToStream(IHttpStreamWriter writer, long count, bool isRequest, SessionEventArgs args, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
CancellationToken cancellationToken)
{
var remainingBytes = count;
@@ -444,9 +444,17 @@ await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHea
break;
if (httpWriter != null)
- await httpWriter.WriteAsync(largeBuf.AsMemory(0, read), cancellationToken);
+ {
+ var writeVt = httpWriter.WriteAsync(largeBuf.AsMemory(0, read), cancellationToken);
+ if (!writeVt.IsCompletedSuccessfully)
+ await writeVt;
+ }
else
- await writer.WriteAsync(largeBuf, 0, read, cancellationToken);
+ {
+ var writeVt = writer.WriteAsync(largeBuf, 0, read, cancellationToken);
+ if (!writeVt.IsCompletedSuccessfully)
+ await writeVt;
+ }
if (isRequest)
args.OnDataSent(largeBuf, 0, read);
@@ -472,9 +480,17 @@ await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHea
// Write the unread window in place — no second pooled rent/copy. Await before the next
// fill: FillBuffer compact-moves streamBuffer and would invalidate this window.
if (httpWriter != null)
- await httpWriter.WriteAsync(streamBuffer.AsMemory(offset, n), cancellationToken);
+ {
+ var writeVt = httpWriter.WriteAsync(streamBuffer.AsMemory(offset, n), cancellationToken);
+ if (!writeVt.IsCompletedSuccessfully)
+ await writeVt;
+ }
else
- await writer.WriteAsync(streamBuffer, offset, n, cancellationToken);
+ {
+ var writeVt = writer.WriteAsync(streamBuffer, offset, n, cancellationToken);
+ if (!writeVt.IsCompletedSuccessfully)
+ await writeVt;
+ }
if (isRequest)
args.OnDataSent(streamBuffer, offset, n);
@@ -500,7 +516,7 @@ await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHea
///
///
///
- protected async ValueTask WriteAsync(RequestResponseBase requestResponse, HeaderBuilder headerBuilder,
+ protected ValueTask WriteAsync(RequestResponseBase requestResponse, HeaderBuilder headerBuilder,
CancellationToken cancellationToken = default)
{
var body = requestResponse.CompressBodyAndUpdateContentLength();
@@ -515,18 +531,49 @@ protected async ValueTask WriteAsync(RequestResponseBase requestResponse, Header
&& !requestResponse.HasTrailingHeaders)
{
headerBuilder.WriteRaw(body);
- await WriteHeadersAsync(headerBuilder, cancellationToken);
- requestResponse.IsBodySent = true;
- return;
+ var coalesceVt = WriteHeadersAsync(headerBuilder, cancellationToken);
+ if (coalesceVt.IsCompletedSuccessfully)
+ {
+ requestResponse.IsBodySent = true;
+ return default;
+ }
+
+ return MarkBodySentAfterWriteAsync(coalesceVt, requestResponse);
}
- await WriteHeadersAsync(headerBuilder, cancellationToken);
+ var headersVt = WriteHeadersAsync(headerBuilder, cancellationToken);
+ if (body == null)
+ return headersVt;
- if (body != null)
+ if (headersVt.IsCompletedSuccessfully)
{
- await WriteBodyAsync(body, requestResponse.IsChunked,
+ var bodyVt = WriteBodyAsync(body, requestResponse.IsChunked,
requestResponse.HasTrailingHeaders ? requestResponse.TrailingHeaders : null, cancellationToken);
- requestResponse.IsBodySent = true;
+ if (bodyVt.IsCompletedSuccessfully)
+ {
+ requestResponse.IsBodySent = true;
+ return default;
+ }
+
+ return MarkBodySentAfterWriteAsync(bodyVt, requestResponse);
}
+
+ return WriteHeadersThenBodyAsync(headersVt, body, requestResponse, cancellationToken);
+ }
+
+ private static async ValueTask MarkBodySentAfterWriteAsync(ValueTask writeVt,
+ RequestResponseBase requestResponse)
+ {
+ await writeVt;
+ requestResponse.IsBodySent = true;
+ }
+
+ private async ValueTask WriteHeadersThenBodyAsync(ValueTask headersVt, byte[] body,
+ RequestResponseBase requestResponse, CancellationToken cancellationToken)
+ {
+ await headersVt;
+ await WriteBodyAsync(body, requestResponse.IsChunked,
+ requestResponse.HasTrailingHeaders ? requestResponse.TrailingHeaders : null, cancellationToken);
+ requestResponse.IsBodySent = true;
}
}
diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Lines.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Lines.cs
index 76692e93f..3383b68cc 100644
--- a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Lines.cs
+++ b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Lines.cs
@@ -63,6 +63,47 @@ internal partial class HttpStream : Stream, IHttpStreamWriter, IHttpStreamReader
return ReadLineFromStreamBufferAsync(cancellationToken);
}
+ ///
+ /// When a complete response status line is already buffered, parse it from bytes (no line string).
+ /// Returns when more socket data is needed.
+ ///
+ protected bool TryParseResponseLineFromBuffer(out Version version, out int statusCode, out string description,
+ out bool emptyLine)
+ {
+ version = HttpHeader.VersionUnknown;
+ statusCode = 0;
+ description = string.Empty;
+ emptyLine = false;
+
+ var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes;
+ var window = streamBuffer.AsSpan(bufferPos, Available);
+ var lfIndex = window.IndexOf((byte)'\n');
+ if (lfIndex < 0)
+ return false;
+
+ if (lfIndex > maxLineBytes)
+ throw new ProxyHttpException(
+ $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.",
+ null, null);
+
+ var line = window.Slice(0, lfIndex);
+ if (line.Length > 0 && line[^1] == (byte)'\r')
+ line = line[..^1];
+
+ var consumed = lfIndex + 1;
+ bufferPos += consumed;
+ Available -= consumed;
+
+ if (line.Length == 0)
+ {
+ emptyLine = true;
+ return true;
+ }
+
+ Response.ParseResponseLine(line, out version, out statusCode, out description);
+ return true;
+ }
+
///
/// When a complete request line is already buffered, parse it from bytes (no line string).
/// Returns when more socket data is needed.
diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs
index f3c51c5f9..2f4387018 100644
--- a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs
+++ b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs
@@ -720,12 +720,11 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey,
// Unlike ProxyDnsRequests=true (where the SOCKS proxy itself resolves the origin and
// this proxy never learns an address to validate - a case the hardening plan explicitly
// leaves for a future design spike), this branch resolves the real origin locally, so it
- // is exactly the case BlockPrivateNetworkDestinations is meant to cover. Checked against
+ // is exactly the case outbound destination policy is meant to cover. Checked against
// the exact address about to be used below, not re-resolved afterward.
- if (proxyServer.BlockPrivateNetworkDestinations &&
- PrivateNetworkGuard.IsBlocked(socksRemoteIpAddresses[0]))
- throw new OutboundDestinationBlockedException(connectHostName,
- socksRemoteIpAddresses[0].ToString());
+ var socksOriginBlock = EvaluateOutboundDestinationPolicy(proxyServer,
+ socksRemoteIpAddresses[0], connectHostName, exemptAsUpstreamProxy: false);
+ if (socksOriginBlock != null) throw socksOriginBlock;
}
var connectTimeoutMs = (int)(sessionArgs?.ConnectTimeout?.TotalMilliseconds
@@ -742,16 +741,16 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey,
async Task<(bool Ok, Socket? Socket, IPEndPoint? Bound, Exception? Error, IPAddress Address)>
ConnectToAddressAsync(IPAddress ipAddress, CancellationToken attemptToken)
{
- // externalProxy == null here means this attempt's target is the real destination
- // (connectHostName), not an operator-configured upstream proxy address, which is
- // always exempt (see BlockPrivateNetworkDestinations). Checked against this exact
- // resolved address, immediately before it is used to connect below - never
- // re-resolving the hostname afterward - so the check cannot be defeated by a DNS
- // answer that changes between validation and use (rebinding).
- if (proxyServer.BlockPrivateNetworkDestinations && externalProxy == null &&
- PrivateNetworkGuard.IsBlocked(ipAddress))
- return (false, null, null,
- new OutboundDestinationBlockedException(hostname, ipAddress.ToString()), ipAddress);
+ // externalProxy != null means this attempt's target is an operator-configured upstream
+ // proxy address, which is always exempt (see BlockPrivateNetworkDestinations /
+ // BlockLoopbackDestinations). Checked against this exact resolved address, immediately
+ // before it is used to connect below - never re-resolving the hostname afterward - so
+ // the check cannot be defeated by a DNS answer that changes between validation and use
+ // (rebinding).
+ var destinationBlock = EvaluateOutboundDestinationPolicy(proxyServer, ipAddress, hostname,
+ exemptAsUpstreamProxy: externalProxy != null);
+ if (destinationBlock != null)
+ return (false, null, null, destinationBlock, ipAddress);
// Select local bind after destination resolution so IPv4/IPv6 adapters can coexist (#951).
var resolvedBind = UpStreamEndPointSelector.Resolve(ipAddress.AddressFamily,
@@ -1195,6 +1194,29 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey,
};
}
+ ///
+ /// Shared outbound destination policy for both the SOCKS-origin check and each Happy Eyeballs
+ /// address attempt. Upstream proxy hops are exempt; when
+ /// is on, only
+ /// is used (loopback included); otherwise
+ /// uses
+ /// alone.
+ ///
+ private static OutboundDestinationBlockedException? EvaluateOutboundDestinationPolicy(
+ ProxyServer proxyServer, IPAddress address, string hostname, bool exemptAsUpstreamProxy)
+ {
+ if (exemptAsUpstreamProxy) return null;
+
+ if (proxyServer.BlockPrivateNetworkDestinations && PrivateNetworkGuard.IsBlocked(address))
+ return new OutboundDestinationBlockedException(hostname, address.ToString());
+
+ if (proxyServer.BlockLoopbackDestinations && PrivateNetworkGuard.IsLoopback(address))
+ return new OutboundDestinationBlockedException(hostname, address.ToString(),
+ OutboundDestinationBlockedException.LoopbackDestinationBlockTag.Instance);
+
+ return null;
+ }
+
///
/// Attaches a fire-and-forget continuation to each still-in-flight Happy Eyeballs attempt after
/// a race has already been decided by a different, faster address, so that a straggler which
diff --git a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
index 0a0155b29..885ddd97e 100644
--- a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
+++ b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs
@@ -77,5 +77,5 @@
// file-properties version disagreed with the package it was published in. Keep both of the values
// below equal to (as Major.Minor.Build.0) whenever that property changes.
-[assembly: AssemblyVersion("7.0.12.0")]
-[assembly: AssemblyFileVersion("7.0.12.0")]
+[assembly: AssemblyVersion("7.0.13.0")]
+[assembly: AssemblyFileVersion("7.0.13.0")]
diff --git a/src/Titanium.Web.Proxy/ProxyServer.cs b/src/Titanium.Web.Proxy/ProxyServer.cs
index cb0558f68..e2be1bb7b 100644
--- a/src/Titanium.Web.Proxy/ProxyServer.cs
+++ b/src/Titanium.Web.Proxy/ProxyServer.cs
@@ -1122,6 +1122,29 @@ internal SemaphoreSlim Http2ToHttp11HttpsOriginCreateGate
///
public bool BlockPrivateNetworkDestinations { get; set; }
+ ///
+ /// Outbound destination policy hook: when , every resolved destination
+ /// IP address is checked for loopback (127.0.0.0/8, ::1, including IPv4-mapped
+ /// forms such as ::ffff:127.0.0.1) before connecting, and the connection attempt is
+ /// rejected with an if it matches.
+ /// Private (RFC 1918), link-local, and cloud metadata addresses (including
+ /// 169.254.169.254) remain reachable under this flag alone.
+ ///
+ /// Off by default. Use when the proxy must allow intranet destinations (for example
+ /// 10.*) but must still stop clients from pivoting to the proxy host's own loopback.
+ /// already covers loopback as part of its
+ /// broader set; when that flag is enabled, this property is not consulted for the reject.
+ /// Assigning does not read or clear this property.
+ ///
+ ///
+ /// Enforced on TCP outbound connects only (the same call sites as
+ /// ). Origin HTTP/3 (QUIC) dials a hostname
+ /// and is not covered. An explicitly configured upstream proxy address is always exempt.
+ /// Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled.
+ ///
+ ///
+ public bool BlockLoopbackDestinations { get; set; }
+
///
/// Which resource-bound is enforced, observed, or disabled, per
/// the plan's rollout section. Read live by each family's enforcement call site - not baked
diff --git a/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt
index e9fd33412..5d66f97ee 100644
--- a/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt
+++ b/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt
@@ -907,7 +907,7 @@ Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs.Count.get -> int
Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs.DataEventArgs(byte[]! buffer, int offset, int count) -> void
Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs.Offset.get -> int
Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader
-Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.CopyBodyAsync(Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter! writer, bool isChunked, long contentLength, bool isRequest, Titanium.Web.Proxy.EventArguments.SessionEventArgs! args, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
+Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.CopyBodyAsync(Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter! writer, bool isChunked, long contentLength, bool isRequest, Titanium.Web.Proxy.EventArguments.SessionEventArgs! args, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.Read(byte[]! buffer, int offset, int count) -> int
Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.ReadAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter
diff --git a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt
index 57c8fa5e2..2512676d4 100644
--- a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt
+++ b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt
@@ -81,6 +81,8 @@ Titanium.Web.Proxy.Models.TransparentProxyEndPoint.BeforeHttpAuthenticate -> Tit
[TWP001]Titanium.Web.Proxy.Models.TransparentProxyEndPoint.MaxInboundUnidirectionalStreams.get -> int
[TWP001]Titanium.Web.Proxy.Models.TransparentProxyEndPoint.MaxInboundUnidirectionalStreams.set -> void
static readonly Titanium.Web.Proxy.Http.KnownHeaders.AltSvc -> Titanium.Web.Proxy.Http.KnownHeader!
+Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations.get -> bool
+Titanium.Web.Proxy.ProxyServer.BlockLoopbackDestinations.set -> void
Titanium.Web.Proxy.ProxyServer.EnableHttpInterception.get -> bool
Titanium.Web.Proxy.ProxyServer.EnableHttpInterception.set -> void
Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors.get -> bool
diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj
index 9b2c5e283..4e01a9db4 100644
--- a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj
+++ b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj
@@ -13,7 +13,7 @@
- 7.0.12
+ 7.0.13