-
Notifications
You must be signed in to change notification settings - Fork 654
Home
A lightweight, asynchronous HTTP(S) proxy server for .NET. This wiki documents the major features and the most common APIs. For the full type reference, see the API documentation.
- Getting started
- Screenshots
- Endpoints
- Decrypting HTTPS
- Intercepting requests and responses
- Modifying bodies
- Custom and redirected responses
- Streaming bodies
- HTTP/2
- HTTP/3
- Tunnel (CONNECT) interception
- Upstream proxies
- Authentication
- Performance and pooling
- Logging and diagnostics
- Request timing
- Supported frameworks
- Breaking changes: unified logging and timing
- Migrating from 4.x to 5.0
- Security considerations
- Protocol feature support
Install from NuGet:
dotnet add package Titanium.Web.ProxyStart an explicit proxy that logs every requested URL:
using System;
using System.Net;
using System.Threading.Tasks;
using Titanium.Web.Proxy;
using Titanium.Web.Proxy.EventArguments;
using Titanium.Web.Proxy.Models;
using var proxyServer = new ProxyServer();
proxyServer.BeforeRequest += OnRequest;
var endPoint = new ExplicitProxyEndPoint(IPAddress.Loopback, 8000, decryptSsl: true);
proxyServer.AddEndPoint(endPoint);
// Create and trust the root certificate used to decrypt HTTPS traffic.
proxyServer.CertificateManager.EnsureRootCertificate(
userTrustRootCertificate: true,
machineTrustRootCertificate: false);
proxyServer.Start();
Console.WriteLine("Proxy listening on 127.0.0.1:8000. Press Enter to stop.");
Console.ReadLine();
proxyServer.Stop();
static Task OnRequest(object sender, SessionEventArgs e)
{
Console.WriteLine(e.HttpClient.Request.Url);
return Task.CompletedTask;
}Configure your client to use 127.0.0.1:8000 as its HTTP and HTTPS proxy.
Example apps live under examples/ in the main repository (Basic, WPF, Windows service).
Basic console example — compact per-request traffic tape:
WPF example — session list with request/response inspection:
Add one or more endpoints before calling Start():
-
ExplicitProxyEndPoint— the client is configured to use the proxy (standardHTTP_PROXY/ system proxy setup). SupportsCONNECTtunneling. -
TransparentProxyEndPoint— traffic is redirected to the proxy without the client knowing (e.g. via routing/NAT). SetGenericCertificateNamefor the server name to present. -
SocksProxyEndPoint— SOCKS4/SOCKS5 endpoint. See SOCKS endpoint for protocol handling details.
proxyServer.AddEndPoint(new ExplicitProxyEndPoint(IPAddress.Loopback, 8000));
proxyServer.AddEndPoint(new TransparentProxyEndPoint(IPAddress.Loopback, 8001, decryptSsl: true)
{
GenericCertificateName = "example.com"
});
proxyServer.AddEndPoint(new SocksProxyEndPoint(IPAddress.Loopback, 1080));SocksProxyEndPoint accepts SOCKS4 and SOCKS5 connections. The client is unaware it is communicating with a proxy; traffic is typically redirected here via a local application configuration or system-level routing.
Protocol routing after the SOCKS handshake:
| Traffic |
decryptSsl: true (default) |
decryptSsl: false |
|---|---|---|
| HTTPS (TLS ClientHello detected) | MITM-decrypted; HTTP(S) interception pipeline runs (BeforeRequest/BeforeResponse/AfterResponse) |
Opaque TCP relay to the SOCKS destination — no inspection |
| Plain HTTP | HTTP interception pipeline runs | HTTP interception pipeline runs |
| Non-HTTP, non-TLS (e.g. SMTP, custom TCP protocol) | Opaque TCP relay to the SOCKS destination | Opaque TCP relay to the SOCKS destination |
Non-HTTP plain traffic is detected by peeking the first bytes. When the opening bytes do not match any known HTTP method, the connection is relayed transparently to the target host and port negotiated during the SOCKS handshake — no HTTP parsing is attempted and no proxy events fire.
To opt individual HTTPS connections out of decryption at runtime, subscribe to BeforeSslAuthenticate on the endpoint:
var socksEndPoint = new SocksProxyEndPoint(IPAddress.Loopback, 1080, decryptSsl: true);
socksEndPoint.BeforeSslAuthenticate += (sender, e) =>
{
if (e.SniHostName.EndsWith(".internal", StringComparison.OrdinalIgnoreCase))
e.DecryptSsl = false; // relay opaquely without decrypting
return Task.CompletedTask;
};
proxyServer.AddEndPoint(socksEndPoint);To inspect HTTPS traffic the proxy generates per-host certificates signed by its own root certificate, which the client must trust.
// Generate (if needed) and trust the root certificate for the current user.
proxyServer.CertificateManager.EnsureRootCertificate(
userTrustRootCertificate: true,
machineTrustRootCertificate: false);Useful CertificateManager members:
-
RootCertificate/RootCertificateName/PfxFilePath— the CA used for signing. -
CreateRootCertificate(...),TrustRootCertificate(...),RemoveTrustedRootCertificate(...). -
SaveFakeCertificates— cache generated leaf certificates on disk. -
CertificateEngine—BouncyCastle(default; distinct key per host),BouncyCastleFast(faster; one shared key for all leaves), orDefaultWindows(Windows only; also shared key). -
LeafCertificateKeyAlgorithm— key algorithm for generated leaf certificates,Rsa2048(default) orEcdsaP256. See first-visit latency. -
LeafRsaKeyPairBufferSize— how many RSA-2048 leaf private keys to keep pre-generated (default8;0disables; max256). Process-wide; unused when leaves are ECDSA P-256.
Only decrypt endpoints where you need to see content; leave decryptSsl: false to pass HTTPS through as an opaque tunnel.
Every distinct MITM'd host gets its own generated leaf certificate, kept in an in-memory cache
(each entry holds a full X509Certificate2 plus private key) so repeat connections to the same
host don't re-run certificate generation. Two independent, nullable knobs on
ProxyResourceLimits bound this:
-
MaxCertificateCacheEntries— caps the in-memory cache.ProxyResourceLimits.Default(and theBalanced/LegacyCompatibleprofiles) set this to1024(roughly 10 MB), comfortably above a typical single-session browsing workload.nulldisables the bound. -
MaxCertificateDiskCacheEntries— caps the on-disk cache used whenSaveFakeCertificatesistrue, independently of the in-memory bound. Disk is far cheaper than a live certificate handle, soDefault/Balanced/LegacyCompatibleleave thisnull(unbounded) so a warm disk cache survives process restarts. ThePublicFacingprofile bounds it at50,000, since untrusted clients can enumerate hostnames and an unbounded disk cache would otherwise be a disk-exhaustion vector.
Both entries in a cached certificate that age out (idle longer than CertificateCacheTimeOutMinutes,
default 60) or get evicted to stay within the bound are disposed a sweep interval later, not
immediately — this gives any TLS handshake that grabbed a reference just before eviction time to
finish, while still reclaiming native key handles well before the next full GC.
Use ProxyResourceLimits.Default.WithCertificateCacheBounds(...) to change either bound without
having to reconstruct every other limit:
proxyServer.ResourceLimits = ProxyResourceLimits.Default.WithCertificateCacheBounds(
maxCertificateCacheEntries: 4096,
maxCertificateDiskCacheEntries: 50_000);The twp.certificates.cached observable gauge (see Logging and diagnostics)
reports live in-memory cache occupancy, so you can confirm the bound is holding instead of
inferring it indirectly from process working set.
Subscribe to the proxy lifecycle events. All handlers are async.
proxyServer.BeforeRequest += OnRequest; // before the request is sent upstream
proxyServer.BeforeResponse += OnResponse; // after response headers are received
proxyServer.AfterResponse += OnAfterResponse;SessionEventArgs exposes HttpClient.Request and HttpClient.Response, headers, the URL, client/process info, and per-session UserData.
Task OnRequest(object sender, SessionEventArgs e)
{
var request = e.HttpClient.Request;
request.Headers.AddHeader("X-Proxy", "titanium");
return Task.CompletedTask;
}Read and replace the whole body (buffers it in memory):
async Task OnResponse(object sender, SessionEventArgs e)
{
if (e.HttpClient.Response.ContentType?.Contains("text/html") == true)
{
var body = await e.GetResponseBodyAsString();
e.SetResponseBodyString(body.Replace("http://", "https://"));
}
}For large or unbounded bodies, prefer the streaming APIs below instead of GetResponseBody().
Answer the client directly, without contacting the server:
proxyServer.BeforeRequest += (sender, e) =>
{
if (e.HttpClient.Request.Url.Contains("blocked.example"))
e.Ok("<html><body>Blocked</body></html>");
return Task.CompletedTask;
};-
e.Ok(html)/e.Ok(bytes)— send a200response. -
e.Respond(response)— send an arbitraryResponse. -
e.Redirect(url)— send a redirect. -
e.TerminateServerConnection()— close the upstream connection instead of reusing it.
When you supply a response after the server was already contacted, the original server body is drained so the connection can be reused; see Draining bodies.
Inspect or modify bodies chunk-by-chunk, or generate a response body from scratch, without buffering it in memory — ideal for large downloads or endless streams (e.g. server-sent events).
proxyServer.OnResponseBodyWrite += (sender, e) =>
{
e.BodyBytes = Transform(e.BodyBytes); // modify each chunk as it streams
return Task.CompletedTask;
};See the dedicated Streaming Bodies page for OnRequestBodyWrite/OnResponseBodyWrite, RespondStreaming, draining, and the HTTP/1.x vs HTTP/2 details.
HTTP/2 support is on by default (negotiated via TLS ALPN only — no cleartext h2c upgrade). To opt out and force HTTP/1.1 only:
proxyServer.EnableHttp2 = false;Header/body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and
the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/RespondStreaming) all work over
HTTP/2 the same as over HTTP/1.x — see Streaming Bodies. WebSocket over HTTP/2
(RFC 8441), including HTTP/1.1 Upgrade → h2 origin on the translation bridge, is opt-in via
EnableRfc8441. Not supported: HTTP/2 server push and cleartext h2c upgrade. See
Protocol Feature Support for the full breakdown.
HTTP/3 support is available as an opt-in feature. See the HTTP/3 page for the full setup guide. Quick start:
proxy.EnableHttp3 = true;
var quicEndPoint = new TransparentQuicProxyEndPoint(IPAddress.Any, 443);
proxy.AddEndPoint(quicEndPoint);
proxy.Start();All existing BeforeRequest/BeforeResponse/AfterResponse event handlers work unchanged for HTTP/3
streams. The proxy auto-discovers HTTP/3 capability via Alt-Svc (and optional background HTTPS/SVCB
DNS) and uses HTTP/3 for subsequent Auto-mode requests once that origin is warm — a cache hit alone
only starts background QUIC warm-up.
On an ExplicitProxyEndPoint, decide per-CONNECT whether to decrypt:
explicitEndPoint.BeforeTunnelConnectRequest += (sender, e) =>
{
var host = e.HttpClient.Request.RequestUri.Host;
if (host.EndsWith("bank.example"))
e.DecryptSsl = false; // pass through without decrypting
return Task.CompletedTask;
};
explicitEndPoint.BeforeTunnelConnectResponse += (sender, e) => Task.CompletedTask;Chain through another proxy, globally or per request:
proxyServer.UpStreamHttpProxy = new ExternalProxy("upstream.example", 8888);
proxyServer.UpStreamHttpsProxy = new ExternalProxy("upstream.example", 8888);
// Or resolve the upstream proxy dynamically:
proxyServer.GetCustomUpStreamProxyFunc = async args =>
{
return new ExternalProxy("upstream.example", 8888);
};
// Detect and reuse the system's configured proxy:
proxyServer.ForwardToUpstreamGateway = true;ExternalProxy supports HTTP, HTTPS, and SOCKS4/5, with optional credentials.
-
Proxy authentication (Basic):
proxyServer.ProxyBasicAuthenticateFunc = async (args, userName, password) => userName == "user" && password == "secret";
-
Windows authentication (Kerberos/NTLM) to upstream servers:
proxyServer.EnableWinAuth = true;
-
Mutual TLS: provide the client certificate via
ClientCertificateSelectionCallback, and validate server certificates withServerCertificateValidationCallback.
-
EnableConnectionPool— reuse idle upstream TCP connections (enabled by default). Only connections that are safe to reuse under HTTP (persistent, body fully received, not authenticated to a specific identity) are pooled; set tofalseto force a fresh connection per client. -
ConnectionTimeOutSeconds,TcpTimeWaitSeconds,ReuseSocket— tune connection lifetime. -
BufferPool/BufferSize— reuse I/O buffers. -
CertificateManager.SaveFakeCertificates— cache generated certificates.
Once a host's certificate is cached, the proxy costs little: measured against Chrome loading google.com, wikipedia.org, news.google.com, youtube.com and jw.org, going through it adds roughly 30 ms to main-document TTFB, which is about what the extra TLS leg and hop should cost.
The first visit to a host is different, because a certificate has to be produced before the browser handshake can finish. An RSA-2048 key pair costs a few hundred milliseconds of CPU, and a page pulling resources from a few dozen not-yet-seen hosts needs one per host — all at once, all CPU-bound, so they inflate each other well past their uncontended cost. Two things bound that:
- Leaf RSA key pairs come from a small buffer that a background task keeps topped up (default size
8viaLeafRsaKeyPairBufferSize;0disables; max256), so a key generated while the proxy was idle is handed over immediately and only a burst longer than the buffer waits on generation at all. - Setting
CertificateManager.LeafCertificateKeyAlgorithmtoCertificateKeyAlgorithm.EcdsaP256issues P-256 leaves instead, which cost a fraction of an RSA key pair to generate while still giving every host its own key. On a cold certificate cache this takes first-visit TTFB from several times the direct baseline down to roughly parity with it. Only clients that accept ECDSA server certificates can be intercepted afterwards — universal among current browsers, but not in much older TLS stacks, which is whyRsa2048remains the default. The root certificate stays RSA either way, so an already-installed and trusted root keeps working.
Honoured by the BouncyCastle engines; the Windows engine always issues RSA.
Every exception the proxy catches — even one handled internally and never surfaced to your code — is
reported through ProxyServer.Logging, a Microsoft.Extensions.Logging-based abstraction. This replaced
the old ExceptionFunc callback; see
Breaking changes: unified logging and timing below if you
are migrating.
// Master switch: false gives zero logging overhead (no timestamps read, no strings formatted).
proxyServer.Logging.Enabled = true;
// Only entries at or above this level are actually written to a sink. Every caught exception is still
// classified and reported to the gateway regardless - this only controls how much reaches a sink.
// Defaults to LogLevel.Error so out-of-the-box behavior stays quiet.
proxyServer.Logging.MinimumLevel = LogLevel.Information;
// Built-in sinks, both asynchronous and best-effort so they never block proxy traffic:
proxyServer.Logging.EnableConsole = true; // default on
proxyServer.Logging.EnableConsoleColors = true; // default on; colors each line by level
proxyServer.Logging.EnableFile = true; // default off
proxyServer.Logging.FilePath = "logs/titanium-proxy.log"; // default path; size-based rolling file
proxyServer.Logging.MaxFileSizeBytes = 10 * 1024 * 1024;
proxyServer.Logging.MaxRolledFiles = 5;
// Changes to the Logging options above only take effect once you (re)apply them - Start() does this
// automatically, but call it yourself to change configuration while already running:
proxyServer.ApplyLoggingConfiguration();To bridge into an existing logging pipeline (Serilog, NLog, an ASP.NET Core host's ILoggerFactory, etc.)
instead of the built-in Console/File sinks, set LoggerFactory — this disables the built-in sinks entirely
and hands every log record to your factory verbatim:
proxyServer.Logging.LoggerFactory = hostLoggerFactory;
proxyServer.ApplyLoggingConfiguration();Exceptions the proxy considers expected/benign under normal operation (client disconnects, cancelled
operations, expected socket resets, retries, and similar) are logged at Debug/Trace so they never
contribute to Error-level noise in the default configuration, while genuinely unexpected failures are
always logged at Error or Critical.
The built-in console sink colors each line by level (dim Trace/Debug, default Information, yellow
Warning, red Error, bold red Critical) so failures stand out while scrolling through busy output.
Colors are automatically suppressed for a stream that is redirected (e.g. proxy.exe > out.log) or when
the NO_COLOR environment variable is set, regardless of
EnableConsoleColors — so redirected output and log files never end up with raw escape codes. The
rolling-file sink is always plain text.
Set EnableRequestTimingCapture to populate structured timing objects for every session; when left
false (the default) no timing object is ever allocated, so there is no cost at all when the feature is
unused.
proxyServer.EnableRequestTimingCapture = true;
proxyServer.AfterResponse += (sender, e) =>
{
var timing = e.Timing; // HttpRequestTiming, or null if capture is disabled
if (timing != null)
{
Console.WriteLine($"Time to first byte: {timing.TimeToFirstByte}");
Console.WriteLine($"Total duration: {timing.TotalDuration}");
Console.WriteLine($"Upstream connection reused: {timing.UpstreamConnectionReused}");
}
return Task.CompletedTask;
};-
SessionEventArgsBase.Timing(HttpRequestTiming) — per-request milestones: when the client's request headers were read, when an upstream connection became ready, when the request was sent, when response headers arrived, and when the session completed — plus derived durations (ConnectionWaitDuration,TimeToFirstByte,ResponseDeliveryDuration,TotalDuration) and retry bookkeeping (AttemptCount,UpstreamConnectionReused). -
SessionEventArgsBase.UpstreamConnectionTiming(UpstreamConnectionTiming) — timing of the underlying upstream TCP/TLS connection itself (DNS resolution, TCP handshake, optional upstream-proxy CONNECT tunnel, TLS handshake). Shared by every session that reuses the same pooled connection. -
TunnelConnectSessionEventArgs.ClientTlsTiming(ClientTlsTiming) — duration of the client-facing (browser-to-proxy) TLS handshake performed while decrypting an HTTPSCONNECTtunnel on an explicit endpoint. -
TunnelConnectSessionEventArgs.ConnectTiming(TunnelConnectTiming) — CONNECT-phase milestones (certificate readiness, HTTP/3 capability source, HTTP/2 probe, browser TLS). Allocated only whenEnableRequestTimingCaptureis true and the tunnel is being decrypted.
- .NET 10
Versions prior to 4.0 also supported .NET Framework 4.6.2 and .NET 8; starting with 4.0, the package targets .NET 10 only.
-
ProxyServer.ExceptionFuncand theExceptionHandlerdelegate were removed. UseProxyServer.Logginginstead — every exception the old callback would have received is now reported through the logging gateway, classified by severity rather than delivered uniformly to a single callback. -
SessionEventArgsBase.TimeLine(the free-formDictionary<string, DateTime>of named milestones) was removed. UseTiming/UpstreamConnectionTiming/ClientTlsTiminginstead, which are strongly typed and only allocated whenEnableRequestTimingCaptureis set. -
ClientConnectionId/ServerConnectionId/HttpRequestTiming.UpstreamConnectionIdchanged fromGuidto process-wide monotoniclongcounters (unbound server id is0, notGuid.Empty). See Connection IDs are monotoniclongcounters, notGuidin the 5.0 migration guide.
5.0 bundles a large security- and correctness-hardening pass — TLS defaults, certificate storage location, HTTP/1 framing strictness, body-size budgets, WebSocket/HTTP-2/HTTP-3 abuse limits, and a few credential/redaction fixes all changed observable behavior in some way. See the dedicated Migration guide: 4.x → 5.0 page for the full list, each with its rationale and remedy.
Wondering whether a specific HTTP/1.x, HTTP/2, or HTTP/3 feature (trailers, interim 1xx responses, HPACK, QPACK, Alt-Svc, server push, ...) is supported? See the Protocol Feature Support page for a full Yes/No/Partial breakdown.