From 5f24323cbdbcf8882178dda9cab3f7220cc1fa82 Mon Sep 17 00:00:00 2001 From: Jehonathan Thomas Date: Sun, 30 Aug 2026 17:47:40 -0500 Subject: [PATCH 01/23] ci(release): drop setup-dotnet cache without packages.lock.json (#992) Co-authored-by: justcoding121 --- .github/workflows/release.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15b920976..063316335 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: release +name: release on: push: @@ -60,7 +60,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - name: Test run: | dotnet test src/Titanium.Web.Proxy.sln -c Release --nologo --filter "TestCategory!=Slow&TestCategory!=E2E-UI-Window" @@ -98,7 +97,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - uses: actions/cache@v4 with: path: tools/packaging/.cache/http3-natives @@ -138,7 +136,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - name: Build Plus env: RELEASE_TAG: ${{ needs.resolve-version.outputs.release_tag }} @@ -185,7 +182,6 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - cache: true - uses: actions/cache@v4 with: path: tools/packaging/.cache/http3-natives From 18ea46b59272496241bef514f4331212219647f4 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 18:03:59 -0500 Subject: [PATCH 02/23] fix: repair long-run MITM cert handles and quiet expected proxy noise (7.0.1) Invalidate SslStreamCertificateContext on leaf eviction so disk-reloaded PKCS#12 handshakes stop failing with m_safeCertContext; harden empty chunk / peer H2 RST / body-budget logging and Inspector buffering. Bump product version to 7.0.1.0. --- .github/workflows/release.yml | 6 +- README.md | 8 +- src/Titanium.Cli/Titanium.Cli.csproj | 2 +- .../Services/InterceptionService.cs | 26 ++++- .../Services/SessionArchive.cs | 2 +- .../Titanium.Inspector.csproj | 2 +- .../PlusInspectorViewProvider.cs | 2 +- src/Titanium.Plus/Titanium.Plus.csproj | 2 +- src/Titanium.Plus/TitaniumPlusModule.cs | 2 +- .../Titanium.Web.Proxy.Abstractions.csproj | 2 +- .../Titanium.Web.Proxy.Configuration.csproj | 2 +- .../Certificates/CertificateManager.cs | 52 +++++++-- src/Titanium.Web.Proxy/Http2/Http2Helper.cs | 32 +++++- .../Network/Streams/LimitedStream.cs | 4 +- .../Properties/AssemblyInfo.cs | 4 +- src/Titanium.Web.Proxy/ProxyServer.cs | 21 +++- .../Titanium.Web.Proxy.csproj | 2 +- .../Titanium.E2E.Tests/CliCommandE2ETests.cs | 4 +- .../CertificateManagerTests.cs | 107 ++++++++++++++++++ .../LimitedStreamReadTests.cs | 16 +++ tools/RpsLoadProbe/PERF-GATES.md | 2 +- tools/packaging/PACKAGING.md | 2 +- 22 files changed, 259 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 063316335..5a2b4b848 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: release +name: release on: push: @@ -209,7 +209,7 @@ jobs: RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} run: | $ver = $env:RELEASE_VERSION - if ($ver -notmatch '^\d+\.\d+\.\d+') { $ver = "7.0.0" } + if ($ver -notmatch '^\d+\.\d+\.\d+') { $ver = "7.0.1" } $ver = ($ver -split "-")[0] ./tools/packaging/build-inspector-msi.ps1 ` -PayloadDir artifacts/inspector/win-x64 ` @@ -318,7 +318,7 @@ jobs: "abstractionsVersion": version.split("-")[0], "products": { "cli": {"assets": {}}, - "plus": {"requiredAbstractions": "7.0.0", "asset": None}, + "plus": {"requiredAbstractions": "7.0.1", "asset": None}, "inspector": {"assets": {}}, }, "nuget": { diff --git a/README.md b/README.md index 5ced78d48..354adab66 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ A lightweight, high-performance HTTP(S) proxy — reverse / edge CLI, desktop In | Product | What it is | How you get it | |---------|------------|----------------| -| **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack: `run`, `test`, `version`, `update` | GitHub Releases, winget, or `dotnet tool` | -| **Titanium Inspector** | Desktop MITM debugger (session grid, inspectors, AutoResponder, breakpoints, HAR) | Windows MSI, winget, or GitHub Releases | -| **Titanium.Plus** | Optional advanced features: control plane, ops, observability, and dashboard | `titanium update --plus` | -| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | NuGet | +| **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack: `run`, `test`, `version`, `update` | [Windows](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0-beta/Titanium.Cli-win-x64.zip), [Linux](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0-beta/Titanium.Cli-linux-x64.zip), [macOS](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0-beta/Titanium.Cli-osx-arm64.zip) ([all downloads](https://titaniumproxy.com/download); winget = stable-only) | +| **Titanium Inspector** | Desktop MITM debugger (session grid, inspectors, AutoResponder, breakpoints, HAR) | [Windows MSI](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0-beta/TitaniumInspector-win-x64.msi), [Windows zip](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0-beta/TitaniumInspector-win-x64.zip) ([Download](https://titaniumproxy.com/download); winget = stable-only) | +| **Titanium.Plus** | Optional advanced features: control plane, ops, observability, and dashboard | [`titanium update --plus --channel beta`](https://titaniumproxy.com/download) or [Plus DLL](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0-beta/Titanium.Plus-v7.0.0-beta.dll) | +| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy/7.0.0-beta) (`dotnet add package Titanium.Web.Proxy --prerelease`) | CLI and Plus target reverse-proxy / edge workloads (routing, load balancing, health, discovery) on Windows, Linux, and macOS. Inspector is the MITM debugging product. The Core library is the embed path for .NET. Requires .NET 10 or later. diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index c5910282c..3afa41f7d 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,7 +7,7 @@ latest enable false - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index 2b9383722..f6708d321 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -108,6 +108,10 @@ public async Task StartAsync(IPAddress address, int port, CancellationToken canc ApplyLoggingOptions(_loggingSettings); _proxy.EnableHttpInterception = true; _proxy.EnableRequestTimingCapture = true; + // Inspector eagerly buffers bodies for the session grid; 4 MiB trips too often on + // normal browsing (images, JS bundles) and RST'd the H2 stream. 32 MiB still bounds + // memory while covering typical inspected payloads. + _proxy.MaxBufferedBodyBytes = 32 * 1024 * 1024; ApplyHttpProtocols(); _proxy.BeforeRequest += OnBeforeRequest; _proxy.BeforeResponse += OnBeforeResponse; @@ -571,7 +575,7 @@ private async Task OnBeforeRequest(object sender, SessionEventArgs e) { try { - if (e.HttpClient.Request.HasBody) + if (e.HttpClient.Request.HasBody && ShouldBufferBody(e.HttpClient.Request, e)) { e.HttpClient.Request.KeepBody = true; await e.GetRequestBody(); @@ -629,7 +633,7 @@ private async Task OnBeforeResponse(object sender, SessionEventArgs e) { try { - if (e.HttpClient.Response.HasBody) + if (e.HttpClient.Response.HasBody && ShouldBufferBody(e.HttpClient.Response, e)) { e.HttpClient.Response.KeepBody = true; await e.GetResponseBody(); @@ -841,6 +845,24 @@ private static string FormatHeaders(HeaderCollection headers) return sb.ToString(); } + /// + /// Whole-body buffering for the session grid must not run when Content-Length already + /// exceeds — that path RSTs HTTP/2 streams + /// with ENHANCE_YOUR_CALM and breaks the browser download. Unknown length still buffers + /// up to the limit (UI truncation via applies afterward). + /// + private bool ShouldBufferBody(RequestResponseBase message, SessionEventArgs session) + { + var limit = session.MaxBufferedBodyBytes ?? _proxy?.MaxBufferedBodyBytes ?? (4 * 1024 * 1024); + if (limit <= 0) + { + return true; + } + + var contentLength = message.ContentLength; + return contentLength < 0 || contentLength <= limit; + } + private static byte[]? TruncateBytes(byte[]? body) { if (body is null || body.Length == 0) diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index 5fbec2a3b..003e1dfea 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -17,7 +17,7 @@ public static async Task ExportHarAsync(IEnumerable sessions, s log = new { version = "1.2", - creator = new { name = "Titanium Inspector", version = "7.0.0" }, + creator = new { name = "Titanium Inspector", version = "7.0.1" }, entries, }, }; diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index 8d1903599..443b7bf6f 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Plus/PlusInspectorViewProvider.cs b/src/Titanium.Plus/PlusInspectorViewProvider.cs index d9a54e62d..52f54535b 100644 --- a/src/Titanium.Plus/PlusInspectorViewProvider.cs +++ b/src/Titanium.Plus/PlusInspectorViewProvider.cs @@ -5,7 +5,7 @@ namespace Titanium.Plus; /// Plus Inspector panels — view provider only (Inspector never calls Apply). public sealed class PlusInspectorViewProvider : IPlusInspectorViewProvider { - public Version RequiredAbstractionsVersion { get; } = new(7, 0, 0); + public Version RequiredAbstractionsVersion { get; } = new(7, 0, 1); public IReadOnlyList CreatePanels(InspectorPanelContext context) { diff --git a/src/Titanium.Plus/Titanium.Plus.csproj b/src/Titanium.Plus/Titanium.Plus.csproj index 688cb7eff..8aed67e74 100644 --- a/src/Titanium.Plus/Titanium.Plus.csproj +++ b/src/Titanium.Plus/Titanium.Plus.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium Web Proxy Plus advanced features plugin (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Plus/TitaniumPlusModule.cs b/src/Titanium.Plus/TitaniumPlusModule.cs index 71b94e943..c92a2ea78 100644 --- a/src/Titanium.Plus/TitaniumPlusModule.cs +++ b/src/Titanium.Plus/TitaniumPlusModule.cs @@ -18,7 +18,7 @@ namespace Titanium.Plus; /// public sealed class TitaniumPlusModule : ITitaniumPlusModule { - public Version RequiredAbstractionsVersion { get; } = new(7, 0, 0); + public Version RequiredAbstractionsVersion { get; } = new(7, 0, 1); public void Apply(PlusActivationContext context) { 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 bc4c5d0ce..b8d8560ed 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 @@ enable True StrongNameKey.snk - 7.0.0 + 7.0.1 Jehonathan Thomas Shared contracts for Titanium Web Proxy routing, clusters, middleware, and plugins. MIT 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 fc57a8ff6..f092aa7fd 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 @@ enable True StrongNameKey.snk - 7.0.0 + 7.0.1 Jehonathan Thomas YAML/JSON configuration binding for Titanium Web Proxy CLI and reverse-proxy documents. MIT diff --git a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs index d31572700..72b98d3a1 100644 --- a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs +++ b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs @@ -249,8 +249,27 @@ private void EnforceCertificateCacheBound() /// private void EvictCertificate(string certificateName) { - if (cachedCertificates.TryRemove(certificateName, out var removed)) - pendingDisposals.Enqueue(new PendingCertificateDisposal(removed.Certificate, DateTime.UtcNow)); + if (!cachedCertificates.TryRemove(certificateName, out var removed)) + return; + + // Drop any SslStreamCertificateContext keyed by this leaf before the deferred dispose. + // With SaveFakeCertificates, the next visit reloads the same PKCS#12 (same thumbprint); + // leaving the context cached would hand SslStream a disposed SafeCertContext + // ("m_safeCertContext is an invalid handle") and permanently break MITM for that host. + InvalidateSslCertificateContext(removed.Certificate); + pendingDisposals.Enqueue(new PendingCertificateDisposal(removed.Certificate, DateTime.UtcNow)); + } + + /// + /// Removes a cached for + /// so a later handshake cannot reuse a context whose underlying + /// has been (or is about to be) disposed. + /// + private void InvalidateSslCertificateContext(X509Certificate2 leaf) + { + var thumbprint = leaf.Thumbprint; + if (thumbprint != null) + sslCertificateContexts.TryRemove(thumbprint, out _); } /// @@ -265,8 +284,13 @@ private void DisposePendingEvictions() var cutoff = DateTime.UtcNow.AddMinutes(-1); while (pendingDisposals.TryPeek(out var pending) && pending.EvictedAtUtc <= cutoff) { - if (pendingDisposals.TryDequeue(out pending)) - try { pending.Certificate.Dispose(); } catch { /* best effort */ } + if (!pendingDisposals.TryDequeue(out pending)) + continue; + + // Belt-and-suspenders: eviction already invalidated, but expired-cache and other + // paths may enqueue without going through EvictCertificate. + InvalidateSslCertificateContext(pending.Certificate); + try { pending.Certificate.Dispose(); } catch { /* best effort */ } } } @@ -787,7 +811,12 @@ private bool TryGetValidCachedCertificate(string certificateName, out X509Certif var now = DateTime.Now; if (cached.Certificate.NotAfter <= now || cached.Certificate.NotBefore > now) { - if (cachedCertificates.TryRemove(certificateName, out var removed)) removed.Certificate.Dispose(); + if (cachedCertificates.TryRemove(certificateName, out var removed)) + { + InvalidateSslCertificateContext(removed.Certificate); + removed.Certificate.Dispose(); + } + return false; } @@ -998,14 +1027,23 @@ internal System.Net.Security.SslStreamCertificateContext CreateSslCertificateCon { var key = leaf.Thumbprint; if (key != null && sslCertificateContexts.TryGetValue(key, out var cached)) - return cached; + { + // Same thumbprint can be a freshly loaded PKCS#12 after the previous X509Certificate2 + // was disposed (idle/bound eviction + SaveFakeCertificates). SslStreamCertificateContext + // pins the original SafeCertContext — never reuse a context built for a different instance. + if (ReferenceEquals(cached.TargetCertificate, leaf)) + return cached; + + sslCertificateContexts.TryRemove(key, out _); + } var created = BuildSslCertificateContext(leaf); if (key == null) return created; - return sslCertificateContexts.GetOrAdd(key, created); + return sslCertificateContexts.AddOrUpdate(key, created, (_, existing) => + ReferenceEquals(existing.TargetCertificate, leaf) ? existing : created); } private System.Net.Security.SslStreamCertificateContext BuildSslCertificateContext(X509Certificate2 leaf) diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs index e3bb80f45..85e2fb653 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs @@ -2719,9 +2719,13 @@ await lockedOwnLegWrite(() => SendRstStreamAsync( if (bodyBudgetBreached && bodyBudgetMode == PolicyMode.Enforce) { - ReportException(logger, new ProxyHttpException( + // Intentional policy enforcement, not a proxy defect — Debug only. + ProxyDiagnostics.ReportBenign(logger, $"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " + - $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", null, args)); + $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", + new ProxyHttpException( + $"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " + + $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", null, args)); var sizeLimitException = new BodySizeLimitExceededException( $"HTTP/2 body byte count {data.Length + length:N0} exceeds the limit of {maxBufferedBodyBytes:N0}."); @@ -3354,12 +3358,16 @@ await lockedOwnLegWrite(async () => // NO_ERROR (0) from the origin is a normal post-response cleanup; CANCEL is the usual // client abort. REFUSED_STREAM is also expected under origin load-shedding / GOAWAY - // races (observed live from github.com/Fastly both direct and via this proxy) - the - // RST is still forwarded to the peer so browsers/HttpClient can retry, but it must - // not flood server logs as a proxy defect. + // races (observed live from github.com/Fastly both direct and via this proxy). + // STREAM_CLOSED is the peer saying the stream is already done (half-close races). + // PROTOCOL_ERROR on a received RST is the peer's assessment — our own framing + // defects are already ReportException'd at the detection site before we send RST. + // Forward the RST either way; do not flood Error logs for peer-initiated codes. if (errorCode != (int)Http2ErrorCode.NoError && errorCode != (int)Http2ErrorCode.Cancel && - errorCode != (int)Http2ErrorCode.RefusedStream) + errorCode != (int)Http2ErrorCode.RefusedStream && + errorCode != (int)Http2ErrorCode.StreamClosed && + errorCode != (int)Http2ErrorCode.ProtocolError) { var direction = isClient ? "client→proxy" : "origin→proxy"; var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)"; @@ -3367,6 +3375,18 @@ await lockedOwnLegWrite(async () => $"HTTP/2 stream error. Error code: {errorCode}; direction: {direction}; " + $"stream: {streamId}; request: {requestUrl}", null, args)); } + else if (logger.IsEnabled(LogLevel.Debug) && + errorCode != (int)Http2ErrorCode.NoError && + errorCode != (int)Http2ErrorCode.Cancel) + { + var direction = isClient ? "client→proxy" : "origin→proxy"; + var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)"; + ProxyDiagnostics.ReportBenign(logger, + $"HTTP/2 peer RST_STREAM. Error code: {errorCode}; direction: {direction}; " + + $"stream: {streamId}; request: {requestUrl}", + new ProxyHttpException( + $"HTTP/2 peer stream reset code {errorCode}", null, args)); + } } if (endStream && rr == null) diff --git a/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs b/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs index f96e3197d..08a641b59 100644 --- a/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs +++ b/src/Titanium.Web.Proxy/Network/Streams/LimitedStream.cs @@ -71,7 +71,9 @@ private async Task GetNextChunkAsync() readChunkTrail = true; var chunkHead = await baseReader.ReadLineAsync(); - if (chunkHead == null) + // null = EOF; empty = blank line (half-closed / framing glitch). Either way there is no + // more chunk payload — treat as end rather than PROTOCOL_ERROR via Invalid chunk length: ''. + if (string.IsNullOrEmpty(chunkHead)) { bytesRemaining = -1; return; diff --git a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs index 570d2a549..fc825435a 100644 --- a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs +++ b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs @@ -65,5 +65,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.0.0")] -[assembly: AssemblyFileVersion("7.0.0.0")] +[assembly: AssemblyVersion("7.0.1.0")] +[assembly: AssemblyFileVersion("7.0.1.0")] diff --git a/src/Titanium.Web.Proxy/ProxyServer.cs b/src/Titanium.Web.Proxy/ProxyServer.cs index 223f29c08..670c57022 100644 --- a/src/Titanium.Web.Proxy/ProxyServer.cs +++ b/src/Titanium.Web.Proxy/ProxyServer.cs @@ -386,7 +386,9 @@ internal void TrimOriginCapabilityCaches() /// /// Requires MsQuic native library and a supported operating-system version /// (). Setting to with - /// no inbound HTTP/3 endpoint configured emits a warning and skips QUIC initialization. + /// 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: (opt-in). /// /// Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate @@ -1681,10 +1683,19 @@ private void ClearEndpointSystemProxyFlags(ProxyProtocolType protocolType) quicListenerCts = new CancellationTokenSource(); else if (EnableHttp3) { - Logger.LogWarning( - "EnableHttp3 is true but no inbound HTTP/3 endpoint is registered. " + - "Add a TransparentQuicProxyEndPoint, or a TransparentProxyEndPoint with EnableHttp3, " + - "before calling Start()."); + // Explicit/SOCKS endpoints speak TCP to the client; EnableHttp3 still correctly + // arms origin-side QUIC (Alt-Svc / H2↔H3 bridge). That is the Inspector/CLI happy + // path — do not warn. Warn only when nothing can use either inbound or origin H3 + // (no client-facing TCP endpoints), which usually means a misconfigured Start(). + var hasTcpClientFacingEndpoint = ProxyEndPoints.Any(e => + e is ExplicitProxyEndPoint or SocksProxyEndPoint or TransparentProxyEndPoint); + if (!hasTcpClientFacingEndpoint) + { + Logger.LogWarning( + "EnableHttp3 is true but no inbound HTTP/3 endpoint is registered. " + + "Add a TransparentQuicProxyEndPoint, or a TransparentProxyEndPoint with EnableHttp3, " + + "before calling Start()."); + } } // UDP-only transparent QUIC first (no TCP on that port). diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj index 02d1d27f8..357563c37 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.0 + 7.0.1 - 7.0.1 + 7.0.0 - - - - - @@ -69,6 +59,19 @@ + + + + + + + @@ -82,10 +85,12 @@ - - + - diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml.cs b/src/Titanium.Inspector/Views/MainWindow.axaml.cs index bf88261fc..cc05b5b00 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml.cs +++ b/src/Titanium.Inspector/Views/MainWindow.axaml.cs @@ -400,7 +400,8 @@ private void ApplySessionColumnHeaderTips() var tip = SessionGridLayout.GetColumnKey(header.Content) switch { "Duration" => "Total request time from session start to complete (milliseconds).", - "TTFB" => "Time to first byte — wait from request sent until response headers arrive (milliseconds).", + "TTFB" => "Time until first response byte (TTFB), in milliseconds.", + "Protocol" => "HTTP/1.1, HTTP/2, … between client and proxy.", "Size" => "Response body size (B below 1 KB, otherwise KB / MB).", _ => null, }; diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml index 37243a542..88fce63fb 100644 --- a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml +++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml @@ -3,45 +3,72 @@ x:Class="Titanium.Inspector.Views.SessionRetentionWindow" AutomationProperties.AutomationId="SessionRetentionWindow" Title="Session retention" - Width="480" Height="420" + Width="560" Height="560" WindowStartupLocation="CenterOwner" CanResize="False"> - + Task ShowDeviceCaSetupAsync(Window? owner, string message); + + /// + /// Confirm resetting Inspector preferences to factory defaults (not the root CA or sessions). + /// + Task ConfirmResetSettingsAsync(Window? owner); } /// Avalonia modal dialogs. @@ -56,6 +61,16 @@ public Task ShowDeviceCaSetupAsync(Window? owner, string message) => accept: "Export CA", cancel: "Close", height: 320); + + public Task ConfirmResetSettingsAsync(Window? owner) => + SimpleConfirmDialog.ShowAsync( + owner, + "Reset Inspector settings", + "Restore bind address, menus, Tools (Composer/Breakpoints/AutoResponder/Scripts), retention, logging, HTTPS host lists, and layout to factory defaults?\n\n" + + "This does not remove the root CA, change OS trust, clear captured sessions, or delete the on-disk body cache. Restart Inspector afterward so retention limits fully apply.", + accept: "Reset settings", + cancel: "Cancel", + height: 300); } /// Scripted answers for unit / E2E-UI tests (no real windows). @@ -65,10 +80,12 @@ public sealed class ScriptedInspectorDialogs : IInspectorDialogs public bool RemoveRootCaResult { get; set; } = true; public bool ElevateRootCaResult { get; set; } = true; public bool DeviceCaSetupResult { get; set; } + public bool ResetSettingsResult { get; set; } = true; public int InstallRootCaCalls { get; private set; } public int RemoveRootCaCalls { get; private set; } public int ElevateRootCaCalls { get; private set; } public int DeviceCaSetupCalls { get; private set; } + public int ResetSettingsCalls { get; private set; } public string? LastDeviceCaSetupMessage { get; private set; } public Task ConfirmInstallRootCaAsync(Window? owner) @@ -95,4 +112,10 @@ public Task ShowDeviceCaSetupAsync(Window? owner, string message) LastDeviceCaSetupMessage = message; return Task.FromResult(DeviceCaSetupResult); } + + public Task ConfirmResetSettingsAsync(Window? owner) + { + ResetSettingsCalls++; + return Task.FromResult(ResetSettingsResult); + } } diff --git a/src/Titanium.Inspector/Services/SettingsService.cs b/src/Titanium.Inspector/Services/SettingsService.cs index eafc0180f..9b6c49227 100644 --- a/src/Titanium.Inspector/Services/SettingsService.cs +++ b/src/Titanium.Inspector/Services/SettingsService.cs @@ -41,8 +41,8 @@ public sealed class InspectorSettings #endif public string? LoggingFilePath { get; set; } - /// Accept upstream TLS that fails normal validation (lab / self-signed hosts). On by default. - public bool IgnoreServerCertificateErrors { get; set; } = true; + /// Accept upstream TLS that fails normal validation (lab / self-signed hosts). Off by default. + public bool IgnoreServerCertificateErrors { get; set; } /// Start listener when the main window opens. public bool AutoStartCapture { get; set; } = true; @@ -113,6 +113,16 @@ public void Save() File.WriteAllText(_path, JsonSerializer.Serialize(Current, JsonOptions)); } + /// + /// Replace preferences with factory defaults and write settings.json. + /// Does not touch the root CA, OS trust stores, or captured sessions / disk body cache. + /// + public void ResetToFactoryDefaults() + { + Current = new InspectorSettings(); + Save(); + } + private InspectorSettings LoadFromDisk() { if (!File.Exists(_path)) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 916617bdf..0dfa44e96 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -140,6 +140,7 @@ public MainWindowViewModel( OpenSessionRetentionCommand = new RelayCommand(OpenSessionRetentionAsync); OpenLoggingSettingsCommand = new RelayCommand(OpenLoggingSettingsAsync); OpenHttpsDecryptHostsCommand = new RelayCommand(OpenHttpsDecryptHostsAsync); + ResetSettingsCommand = new RelayCommand(ResetSettingsAsync); ReplayCommand = new RelayCommand(async () => await ReplaySelectedAsync()); LoadFromSelectedCommand = new RelayCommand(LoadFromSelectedAsync); LoadIntoComposerCommand = new RelayCommand(LoadIntoComposerAsync); @@ -660,6 +661,22 @@ private async Task OpenHttpsDecryptHostsAsync() : "HTTPS sites to decrypt cancelled"; } + private async Task ResetSettingsAsync() + { + var owner = TryGetMainWindow(); + if (!await _dialogs.ConfirmResetSettingsAsync(owner)) + { + StatusText = "Reset settings cancelled"; + return; + } + + _settings.ResetToFactoryDefaults(); + LoadFromSettings(); + NotifySettingsUiChanged(); + StatusText = + "Settings restored to defaults — restart Inspector so retention limits fully apply. Root CA and sessions were not changed."; + } + private void ApplyDecryptHostListsFromSettings() { var s = _settings.Current; @@ -978,6 +995,7 @@ private Task ApplyEditBodyAsync() public ICommand OpenSessionRetentionCommand { get; } public ICommand OpenLoggingSettingsCommand { get; } public ICommand OpenHttpsDecryptHostsCommand { get; } + public ICommand ResetSettingsCommand { get; } public ICommand ReplayCommand { get; } public ICommand LoadFromSelectedCommand { get; } public ICommand LoadIntoComposerCommand { get; } @@ -1495,13 +1513,10 @@ private void LoadFromSettings() _launchAutoStartCapture = _autoStartCapture = s.AutoStartCapture; _launchAutoSystemProxyOnStart = _autoSystemProxyOnStart = s.AutoSystemProxyOnStart; _decryptHttps = s.DecryptHttps; - AutoResponder.Enabled = s.AutoResponderEnabled; - AutoResponder.LoadFromDtos(s.AutoResponderRules); - Breakpoints.Enabled = s.BreakpointEnabled; - Breakpoints.UrlFilter = string.IsNullOrEmpty(s.BreakpointUrlFilter) ? "*" : s.BreakpointUrlFilter; _breakpointOnResponse = s.BreakpointOnResponse; _scriptOnRequest = s.ScriptOnRequest; _scriptOnResponse = s.ScriptOnResponse; + // Apply interception flags before AutoResponder/Breakpoints mutations — those can PersistSettings. _interception.BreakpointOnResponse = _breakpointOnResponse; _interception.ScriptOnRequest = _scriptOnRequest; _interception.ScriptOnResponse = _scriptOnResponse; @@ -1510,6 +1525,25 @@ private void LoadFromSettings() ApplyDecryptHostListsFromSettings(); _debugFileLogging = IsDebugFileLoggingEnabled(s); _interception.ConfigureLogging(s); + + AutoResponder.Enabled = s.AutoResponderEnabled; + AutoResponder.LoadFromDtos(s.AutoResponderRules); + Breakpoints.Enabled = s.BreakpointEnabled; + Breakpoints.UrlFilter = string.IsNullOrEmpty(s.BreakpointUrlFilter) ? "*" : s.BreakpointUrlFilter; + } + + private void NotifySettingsUiChanged() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindAddress))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPort))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoStartCapture))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoSystemProxyOnStart))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IgnoreServerCertificateErrors))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BreakpointOnResponse))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ScriptOnRequest))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ScriptOnResponse))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DebugFileLogging))); } private static bool IsDebugFileLoggingEnabled(InspectorSettings s) => diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml index 70b0b3258..e578c78c3 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml +++ b/src/Titanium.Inspector/Views/MainWindow.axaml @@ -71,6 +71,9 @@ + + diff --git a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs index 9fdd33759..1ad272d27 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs @@ -43,6 +43,7 @@ public class AutomationIdCoverageHeadlessTests "MenuHttpsDecryptHosts", "MenuIgnoreServerCertErrors", "MenuLogging", + "MenuResetSettings", "MenuHelp", "MenuCheckForUpdates", "SearchBox", diff --git a/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs b/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs index 366c21bff..df262bcbc 100644 --- a/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs +++ b/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs @@ -155,12 +155,109 @@ public void IgnoreServerCertificateErrors_PersistsViaViewModel() settings, new InterceptionService(new RecordingSystemProxyController())); - Assert.IsTrue(vm.IgnoreServerCertificateErrors); - vm.IgnoreServerCertificateErrors = false; - Assert.IsFalse(settings.Current.IgnoreServerCertificateErrors); + Assert.IsFalse(vm.IgnoreServerCertificateErrors); + vm.IgnoreServerCertificateErrors = true; + Assert.IsTrue(settings.Current.IgnoreServerCertificateErrors); var loaded = new SettingsService(path); - Assert.IsFalse(loaded.Current.IgnoreServerCertificateErrors); + Assert.IsTrue(loaded.Current.IgnoreServerCertificateErrors); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [TestMethod] + public async Task ResetSettings_RestoresFactoryDefaults_WithoutClearingSessions() + { + var path = Path.Combine(Path.GetTempPath(), "twp-reset-settings-" + Guid.NewGuid().ToString("N") + ".json"); + try + { + var settings = new SettingsService(path); + settings.Current.BindPort = 9999; + settings.Current.IgnoreServerCertificateErrors = true; + settings.Current.DecryptHttps = true; + settings.Current.DecryptSkipHosts = ["*.corp.example.com"]; + settings.Current.MaxSessionsInMemory = 42; + settings.Save(); + + var dialogs = new ScriptedInspectorDialogs { ResetSettingsResult = true }; + var registry = new SessionRegistry(); + registry.Add(new SessionSnapshot + { + Id = 1, + Method = "GET", + Url = "https://example.com/", + Host = "example.com", + }); + using var interception = new InterceptionService(new RecordingSystemProxyController()); + var vm = new ViewModels.MainWindowViewModel( + new SessionStreamBuffer(registry), + registry, + new UpdateService(settings), + settings, + interception, + dialogs); + + Assert.AreEqual(9999, vm.BindPort); + Assert.IsTrue(vm.IgnoreServerCertificateErrors); + Assert.AreEqual(1, registry.VisibleSessions.Count); + + vm.ResetSettingsCommand.Execute(null); + await Task.Delay(150); + + Assert.AreEqual(1, dialogs.ResetSettingsCalls); + Assert.AreEqual(8866, vm.BindPort); + Assert.IsFalse(vm.IgnoreServerCertificateErrors); + Assert.IsFalse(vm.DecryptHttps); + Assert.AreEqual(0, settings.Current.DecryptSkipHosts.Count); + Assert.AreEqual(10_000, settings.Current.MaxSessionsInMemory); + Assert.AreEqual(1, registry.VisibleSessions.Count); + StringAssert.Contains(vm.StatusText, "Root CA and sessions were not changed"); + + var reloaded = new SettingsService(path).Current; + Assert.AreEqual(8866, reloaded.BindPort); + Assert.IsFalse(reloaded.IgnoreServerCertificateErrors); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [TestMethod] + public async Task ResetSettings_Cancelled_LeavesSettingsUnchanged() + { + var path = Path.Combine(Path.GetTempPath(), "twp-reset-cancel-" + Guid.NewGuid().ToString("N") + ".json"); + try + { + var settings = new SettingsService(path); + settings.Current.BindPort = 7777; + settings.Save(); + + var dialogs = new ScriptedInspectorDialogs { ResetSettingsResult = false }; + var registry = new SessionRegistry(); + using var interception = new InterceptionService(new RecordingSystemProxyController()); + var vm = new ViewModels.MainWindowViewModel( + new SessionStreamBuffer(registry), + registry, + new UpdateService(settings), + settings, + interception, + dialogs); + + vm.ResetSettingsCommand.Execute(null); + await Task.Delay(150); + + Assert.AreEqual(7777, vm.BindPort); + StringAssert.Contains(vm.StatusText, "Reset settings cancelled"); } finally { diff --git a/tests/Titanium.Inspector.Tests/SettingsPersistenceTests.cs b/tests/Titanium.Inspector.Tests/SettingsPersistenceTests.cs index b8bd2b028..2af0e791f 100644 --- a/tests/Titanium.Inspector.Tests/SettingsPersistenceTests.cs +++ b/tests/Titanium.Inspector.Tests/SettingsPersistenceTests.cs @@ -94,7 +94,7 @@ public void InspectorSettings_DefaultLogging_MatchesBuildPosture() { var settings = new InspectorSettings(); Assert.IsTrue(settings.LoggingEnabled); - Assert.IsTrue(settings.IgnoreServerCertificateErrors); + Assert.IsFalse(settings.IgnoreServerCertificateErrors); #if DEBUG Assert.IsTrue(settings.LoggingEnableFile); Assert.AreEqual("Debug", settings.LoggingMinimumLevel); diff --git a/website/docs/inspector.md b/website/docs/inspector.md index b5d05326f..69d13cb8a 100644 --- a/website/docs/inspector.md +++ b/website/docs/inspector.md @@ -28,7 +28,7 @@ Default bind is typically `127.0.0.1:8866`. Bind address/port are **start-time** HTTPS stays encrypted (opaque tunnels) until **Decrypt HTTPS** is enabled. -Capture menu latching options (**Capturing**, **Decrypt HTTPS**, **System proxy**, auto-start prefs) show a check when on. Preferences such as **Session retention…**, **HTTPS sites to decrypt…**, **Ignore insecure server certificates** (on by default), and **Logging…** live under **Options**. +Capture menu latching options (**Capturing**, **Decrypt HTTPS**, **System proxy**, auto-start prefs) show a check when on. Preferences such as **Session retention…**, **HTTPS sites to decrypt…**, **Ignore insecure server certificates** (off by default), and **Logging…** live under **Options**. **Reset Inspector settings…** restores preferences to factory defaults; it does not remove the root CA or clear sessions. The status strip keeps command feedback on the left and a live **Sessions: N** count on the right, so capture traffic does not wipe tips or export paths. From 298be1d97fadf071ca323f775cbe47bc0e9d6227 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 19:31:07 -0500 Subject: [PATCH 09/23] fix: harden origin parse, IPv6 soft-skip, ALPN, and Inspector CA hygiene Prevent Alt-Svc AfterResponse UriFormatException, extend IPv6 unreachable soft-skip to 5m, skip TLS downgrade on ALPN mismatch, scope Inspector leaf crts beside rootCert.pfx, purge same-CN orphan roots, and add Rotate CA plus legacy shared crts prune. --- .../Services/IInspectorDialogs.cs | 24 +++ .../Services/InterceptionService.cs | 121 ++++++++++++ .../ViewModels/MainWindowViewModel.cs | 51 +++++ src/Titanium.Inspector/Views/MainWindow.axaml | 1 + .../Cache/DefaultCertificateDiskCache.cs | 38 +++- .../Certificates/CertificateManager.cs | 99 ++++++++-- .../Handlers/Http3DiscoveryHandler.cs | 18 ++ src/Titanium.Web.Proxy/Http/Request.cs | 15 +- .../Network/TcpConnection/AlpnNegotiation.cs | 56 ++++++ .../TcpConnection/Ipv6UnreachableSoftSkip.cs | 2 +- .../TcpConnection/TcpConnectionFactory.cs | 13 +- .../PublicAPI.Unshipped.txt | 1 + .../AutomationIdCoverageHeadlessTests.cs | 1 + .../RotateRootCaTests.cs | 175 ++++++++++++++++++ .../AbsoluteRootLeafCacheTests.cs | 172 +++++++++++++++++ .../AlpnNegotiationTests.cs | 57 ++++++ .../Ipv6UnreachableSoftSkipTests.cs | 34 ++++ .../RequestOriginHostPortTests.cs | 103 +++++++++++ website/docs/inspector.md | 4 +- wiki/Home.md | 2 +- wiki/Migration-4.x-to-5.0.md | 2 +- 21 files changed, 966 insertions(+), 23 deletions(-) create mode 100644 src/Titanium.Web.Proxy/Network/TcpConnection/AlpnNegotiation.cs create mode 100644 tests/Titanium.Inspector.Tests/RotateRootCaTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/AbsoluteRootLeafCacheTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/AlpnNegotiationTests.cs create mode 100644 tests/Titanium.Web.Proxy.UnitTests/RequestOriginHostPortTests.cs diff --git a/src/Titanium.Inspector/Services/IInspectorDialogs.cs b/src/Titanium.Inspector/Services/IInspectorDialogs.cs index b0708d974..c7e11b4ae 100644 --- a/src/Titanium.Inspector/Services/IInspectorDialogs.cs +++ b/src/Titanium.Inspector/Services/IInspectorDialogs.cs @@ -20,6 +20,9 @@ public interface IInspectorDialogs /// Task ShowDeviceCaSetupAsync(Window? owner, string message); + /// Ask to rotate (regenerate) the Titanium root CA. Returns true if confirmed. + Task ConfirmRotateRootCaAsync(Window? owner); + /// /// Confirm resetting Inspector preferences to factory defaults (not the root CA or sessions). /// @@ -62,6 +65,19 @@ public Task ShowDeviceCaSetupAsync(Window? owner, string message) => cancel: "Close", height: 320); + public Task ConfirmRotateRootCaAsync(Window? owner) => + SimpleConfirmDialog.ShowAsync( + owner, + "Rotate root CA", + "Generate a new Titanium Inspector root CA private key?\n\n" + + "• All same-name Titanium roots are removed from the current-user Trusted Root store\n" + + "• Disk leaf certificates for this install are cleared\n" + + "• You must Install root CA again (or enable Decrypt HTTPS) to re-trust\n\n" + + "Stop capture is recommended before rotating.", + accept: "Rotate", + cancel: "Cancel", + height: 320); + public Task ConfirmResetSettingsAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, @@ -81,11 +97,13 @@ public sealed class ScriptedInspectorDialogs : IInspectorDialogs public bool ElevateRootCaResult { get; set; } = true; public bool DeviceCaSetupResult { get; set; } public bool ResetSettingsResult { get; set; } = true; + public bool RotateRootCaResult { get; set; } = true; public int InstallRootCaCalls { get; private set; } public int RemoveRootCaCalls { get; private set; } public int ElevateRootCaCalls { get; private set; } public int DeviceCaSetupCalls { get; private set; } public int ResetSettingsCalls { get; private set; } + public int RotateRootCaCalls { get; private set; } public string? LastDeviceCaSetupMessage { get; private set; } public Task ConfirmInstallRootCaAsync(Window? owner) @@ -106,6 +124,12 @@ public Task ConfirmElevateRootCaAsync(Window? owner) return Task.FromResult(ElevateRootCaResult); } + public Task ConfirmRotateRootCaAsync(Window? owner) + { + RotateRootCaCalls++; + return Task.FromResult(RotateRootCaResult); + } + public Task ShowDeviceCaSetupAsync(Window? owner, string message) { DeviceCaSetupCalls++; diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index 63f4f613d..5f22732eb 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -146,6 +146,8 @@ public async Task StartAsync(IPAddress address, int port, CancellationToken canc IsRootTrusted = UseInMemoryTrustState ? _inMemoryTrusted : IsRootPresentInStore(machineStore: false); + TryPruneLegacySharedCrtsOnce(); + if (AutoTrustRootOnStart) { InstallRootCertificate(machineStore: false); @@ -425,6 +427,125 @@ public void UntrustRootCertificate(bool machineStore) IsRootTrusted = IsRootPresentInStore(machineStore); } + /// + /// Mint a new root CA: untrust same-CN store entries, delete Inspector PFX + local leaf cache, + /// recreate root. Always best-effort prunes the legacy shared Titanium.Web.Proxy/crts folder. + /// Does not install trust — caller should prompt Install CA. + /// + public bool RotateRootCertificate(bool machineStore) + { + if (_proxy is null) + return false; + + EnsureRootPfxPath(); + var mgr = _proxy.CertificateManager; + + if (!UseInMemoryTrustState) + mgr.RemoveTrustedRootCertificate(machineStore); + else + { + _inMemoryTrusted = false; + IsRootTrusted = false; + } + + mgr.ClearRootCertificate(); + + try + { + if (File.Exists(_rootPfxPath)) + File.Delete(_rootPfxPath); + } + catch + { + // best-effort + } + + try + { + var localCrts = Path.Combine(Path.GetDirectoryName(_rootPfxPath!)!, "crts"); + if (Directory.Exists(localCrts)) + Directory.Delete(localCrts, recursive: true); + } + catch + { + // best-effort + } + + mgr.PfxFilePath = _rootPfxPath!; + var ok = mgr.CreateRootCertificate(persistToFile: true); + IsRootTrusted = UseInMemoryTrustState ? false : IsRootPresentInStore(machineStore); + + PruneLegacySharedCrts(force: true); + return ok && mgr.RootCertificate != null; + } + + /// Test seam: override marker + shared-crts paths under a temp directory. + public string? LegacyCrtsTestRoot { get; set; } + + private string LegacySharedCrtsMarkerPath() + { + EnsureRootPfxPath(); + var dir = LegacyCrtsTestRoot ?? Path.GetDirectoryName(_rootPfxPath!)!; + return Path.Combine(dir, "legacy-shared-crts-cleared"); + } + + private string ResolveLegacySharedCrtsDirectory() + { + if (LegacyCrtsTestRoot != null) + return Path.Combine(LegacyCrtsTestRoot, "shared-crts"); + return Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory(); + } + + private void TryPruneLegacySharedCrtsOnce() + { + var marker = LegacySharedCrtsMarkerPath(); + if (File.Exists(marker)) + return; + PruneLegacySharedCrts(force: false); + } + + /// + /// Best-effort delete of shared Titanium.Web.Proxy/crts (never the shared root PFX). + /// When is false, writes the one-time Start marker. + /// + public void PruneLegacySharedCrts(bool force) + { + try + { + var sharedCrts = ResolveLegacySharedCrtsDirectory(); + if (Directory.Exists(sharedCrts)) + Directory.Delete(sharedCrts, recursive: true); + } + catch + { + // best-effort + } + + if (!force) + { + try + { + File.WriteAllText(LegacySharedCrtsMarkerPath(), DateTime.UtcNow.ToString("O")); + } + catch + { + // best-effort + } + } + else + { + // Rotate always prunes; also ensure marker exists so Start won't re-hit aggressively. + try + { + File.WriteAllText(LegacySharedCrtsMarkerPath(), DateTime.UtcNow.ToString("O")); + } + catch + { + // best-effort + } + } + } + public bool RefreshTrustState(bool machineStore = false) { IsRootTrusted = UseInMemoryTrustState ? _inMemoryTrusted : IsRootPresentInStore(machineStore); diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 0dfa44e96..36f42824e 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -134,6 +134,7 @@ public MainWindowViewModel( ToggleSystemProxyCommand = new RelayCommand(ToggleSystemProxyAsync); InstallCaCommand = new RelayCommand(InstallCaAsync); UntrustCaCommand = new RelayCommand(UntrustCaAsync); + RotateCaCommand = new RelayCommand(RotateCaAsync); ExportCaCommand = new RelayCommand(ExportCaAsync); DeviceCaSetupCommand = new RelayCommand(DeviceCaSetupAsync); OpenLoopbackExemptCommand = new RelayCommand(OpenLoopbackExemptAsync); @@ -563,6 +564,55 @@ private async Task UntrustCaAsync() : "Root CA removed from current user store; Decrypt HTTPS is off until you install the CA again"; } + private async Task RotateCaAsync() + { + if (!_interception.IsRunning) + { + StatusText = "Start the proxy first"; + return; + } + + var owner = TryGetMainWindow(); + if (!await _dialogs.ConfirmRotateRootCaAsync(owner)) + { + StatusText = "Rotate root CA cancelled"; + return; + } + + if (DecryptHttps) + SetDecryptHttpsCore(false); + + var oldThumb = _interception.RootCertificate?.Thumbprint; + var ok = _interception.RotateRootCertificate(machineStore: false); + if (!ok) + { + StatusText = "Rotate root CA failed — see logs"; + return; + } + + var newThumb = _interception.RootCertificate?.Thumbprint; + var changed = !string.IsNullOrEmpty(newThumb) && + !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); + + if (await _dialogs.ConfirmInstallRootCaAsync(owner)) + { + var trusted = _interception.InstallRootCertificate(machineStore: false); + if (!trusted && await _dialogs.ConfirmElevateRootCaAsync(owner)) + trusted = _interception.InstallRootCertificateAsAdmin(machineStore: false); + + StatusText = trusted + ? (changed + ? "Root CA rotated and trusted — enable Decrypt HTTPS when ready" + : "Root CA recreate completed and trusted") + : "Root CA rotated but trust failed — use Install root CA or Export CA"; + return; + } + + StatusText = changed + ? "Root CA rotated — Install root CA (or enable Decrypt HTTPS) to trust the new certificate" + : "Root CA recreate completed — Install root CA to trust"; + } + private Task ExportCaAsync() { var path = _interception.ExportRootCertificate(); @@ -989,6 +1039,7 @@ private Task ApplyEditBodyAsync() public ICommand ToggleSystemProxyCommand { get; } public ICommand InstallCaCommand { get; } public ICommand UntrustCaCommand { get; } + public ICommand RotateCaCommand { get; } public ICommand ExportCaCommand { get; } public ICommand DeviceCaSetupCommand { get; } public ICommand OpenLoopbackExemptCommand { get; } diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml index e578c78c3..06789d7d6 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml +++ b/src/Titanium.Inspector/Views/MainWindow.axaml @@ -47,6 +47,7 @@ AutomationProperties.AutomationId="MenuToggleSystemProxy" /> + + /// When an absolute root PFX path has been used, leaf crts/ lives beside that file + /// instead of under the shared directory. + /// + private string? absoluteLeafBaseDirectory; + private string? rootCertificatePath; + /// + /// Shared default leaf-cache directory (%LocalAppData%/Titanium.Web.Proxy/crts on Windows). + /// Used by Inspector to prune legacy leaves after migrating to an absolute root path. + /// + public static string GetSharedLeafCertificateDirectory() + { + string basePath; + if (RunTime.IsWindows) + basePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + else + basePath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + + return Path.Combine(basePath, AppDirectoryName, DefaultCertificateDirectoryName); + } + public X509Certificate2? LoadRootCertificate(string pathOrName, string password, X509KeyStorageFlags storageFlags) { var path = GetRootCertificatePath(pathOrName); + RememberAbsoluteLeafBase(pathOrName, path); return LoadCertificate(path, password, storageFlags); } public void SaveRootCertificate(string pathOrName, string password, X509Certificate2 certificate) { var path = GetRootCertificatePath(pathOrName); + RememberAbsoluteLeafBase(pathOrName, path); var exported = certificate.Export(X509ContentType.Pkcs12, password); WriteFileAtomic(path, exported); } @@ -167,13 +190,22 @@ private string GetRootCertificatePath(string pathOrName) { if (Path.IsPathRooted(pathOrName)) return pathOrName; - return Path.Combine(GetRootCertificateDirectory(), + return Path.Combine(GetSharedRootCertificateDirectory(), string.IsNullOrEmpty(pathOrName) ? DefaultRootCertificateFileName : pathOrName); } + private void RememberAbsoluteLeafBase(string pathOrName, string resolvedRootPath) + { + if (!Path.IsPathRooted(pathOrName)) return; + + var dir = Path.GetDirectoryName(resolvedRootPath); + if (!string.IsNullOrEmpty(dir)) + absoluteLeafBaseDirectory = dir; + } + private string GetCertificatePath(bool create) { - var path = GetRootCertificateDirectory(); + var path = absoluteLeafBaseDirectory ?? GetSharedRootCertificateDirectory(); var certPath = Path.Combine(path, DefaultCertificateDirectoryName); if (create && !Directory.Exists(certPath)) Directory.CreateDirectory(certPath); @@ -189,7 +221,7 @@ private string GetCertificatePath(bool create) /// location" for a file holding the root CA's private key. 5.0.0 is unreleased, so a clean move /// is preferred over a dual-path migration that would have to keep checking the old spot forever. /// - private string GetRootCertificateDirectory() + private string GetSharedRootCertificateDirectory() { if (rootCertificatePath == null) { diff --git a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs index 72b98d3a1..59573096b 100644 --- a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs +++ b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs @@ -617,6 +617,84 @@ private static X509Certificate2Collection FindCertificates(StoreName storeName, } } + /// + /// Returns when has the expected common name + /// and, when is set, a different thumbprint (an orphan). + /// When is , any matching CN is selected + /// (used when removing all same-name roots). + /// + internal static bool IsSameCommonNameStoreCandidate( + X509Certificate2 candidate, string expectedCommonName, string? keepThumbprint) + { + if (string.IsNullOrEmpty(expectedCommonName) || candidate.Subject.Length == 0) + return false; + + // Subject is typically "CN=Titanium Root Certificate Authority" (plus optional other RDNs). + var cnPrefix = "CN=" + expectedCommonName; + if (!candidate.Subject.Contains(cnPrefix, StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate.GetNameInfo(X509NameType.SimpleName, false), expectedCommonName, + StringComparison.OrdinalIgnoreCase)) + return false; + + if (keepThumbprint == null) + return true; + + return !string.Equals(candidate.Thumbprint, keepThumbprint, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Removes Root/My store certificates that share . + /// When is , the current + /// thumbprint is preserved (orphan cleanup after install). + /// When , every matching CN is removed (Remove CA). + /// + private void RemoveOrphanedSameCommonNameCertificates(StoreLocation storeLocation, bool keepCurrentThumbprint) + { + var expectedCn = RootCertificateName; + var keepThumb = keepCurrentThumbprint ? RootCertificate?.Thumbprint : null; + RemoveMatchingCertificates(StoreName.Root, storeLocation, expectedCn, keepThumb); + RemoveMatchingCertificates(StoreName.My, storeLocation, expectedCn, keepThumb); + } + + private void RemoveMatchingCertificates( + StoreName storeName, StoreLocation storeLocation, string expectedCn, string? keepThumbprint) + { + try + { + using var store = new X509Store(storeName, storeLocation); + store.Open(OpenFlags.ReadWrite); + var toRemove = new List(); + foreach (var cert in store.Certificates) + { + if (IsSameCommonNameStoreCandidate(cert, expectedCn, keepThumbprint)) + toRemove.Add(cert); + } + + foreach (var cert in toRemove) + { + try + { + store.Remove(cert); + } + catch (Exception e) + { + OnException(new Exception( + $"Failed to remove same-CN certificate '{cert.Thumbprint}' from {storeName}\\{storeLocation}.", + e)); + } + finally + { + cert.Dispose(); + } + } + } + catch (Exception e) + { + OnException(new Exception( + $"Failed to open {storeName}\\{storeLocation} for same-CN root cleanup.", e)); + } + } + /// /// Make current machine trust the Root Certificate used by this proxy /// @@ -1259,6 +1337,7 @@ public void TrustRootCertificate(bool machineTrusted = false) InstallCertificate(StoreName.My, StoreLocation.CurrentUser); // currentUser\Root InstallCertificate(StoreName.Root, StoreLocation.CurrentUser); + RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true); if (machineTrusted) { @@ -1266,6 +1345,7 @@ public void TrustRootCertificate(bool machineTrusted = false) InstallCertificate(StoreName.My, StoreLocation.LocalMachine); // localMachine\Root InstallCertificate(StoreName.Root, StoreLocation.LocalMachine); + RemoveOrphanedSameCommonNameCertificates(StoreLocation.LocalMachine, keepCurrentThumbprint: true); } // On macOS/Linux, also trust for SSL in Keychain / NSS so browsers accept MITM. @@ -1290,6 +1370,7 @@ public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) // currentUser\Personal + currentUser\Root (machine elevation is only needed for LocalMachine). InstallCertificate(StoreName.My, StoreLocation.CurrentUser); InstallCertificate(StoreName.Root, StoreLocation.CurrentUser); + RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true); if (!RunTime.IsWindows) { @@ -1416,18 +1497,11 @@ public bool IsRootCertificateMachineTrusted() /// public void RemoveTrustedRootCertificate(bool machineTrusted = false) { - // currentUser\personal - UninstallCertificate(StoreName.My, StoreLocation.CurrentUser, RootCertificate); - // currentUser\Root - UninstallCertificate(StoreName.Root, StoreLocation.CurrentUser, RootCertificate); + // Drop every same-CN Titanium root (current + orphans) so Remove CA leaves a clean store. + RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: false); if (machineTrusted) - { - // localMachine\personal - UninstallCertificate(StoreName.My, StoreLocation.LocalMachine, RootCertificate); - // localMachine\Root - UninstallCertificate(StoreName.Root, StoreLocation.LocalMachine, RootCertificate); - } + RemoveOrphanedSameCommonNameCertificates(StoreLocation.LocalMachine, keepCurrentThumbprint: false); if (!RunTime.IsWindows && RootCertificate != null) Helpers.UnixCertificateTrust.UntrustUserSsl(RootCertificate, RootCertificateName); @@ -1439,9 +1513,8 @@ public void RemoveTrustedRootCertificate(bool machineTrusted = false) /// Should also remove from machine store? public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) { - // currentUser\Personal + currentUser\Root - UninstallCertificate(StoreName.My, StoreLocation.CurrentUser, RootCertificate); - UninstallCertificate(StoreName.Root, StoreLocation.CurrentUser, RootCertificate); + // Current-user: remove all same-CN entries (current + orphans) without elevation. + RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: false); if (!RunTime.IsWindows) { diff --git a/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs index 6f428c3c7..d5bffcf60 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http3DiscoveryHandler.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Titanium.Web.Proxy.EventArguments; using Titanium.Web.Proxy.Http3; +using Titanium.Web.Proxy.Logging; using Titanium.Web.Proxy.Models; namespace Titanium.Web.Proxy; @@ -16,6 +17,21 @@ public partial class ProxyServer /// is in effect). /// private void TryUpdateHttp3CapabilityFromResponse(SessionEventArgs args) + { + try + { + TryUpdateHttp3CapabilityFromResponseCore(args); + } + catch (Exception ex) when (ex is UriFormatException or FormatException) + { + // Alt-Svc bookkeeping must never fail AfterResponse (e.g. path-only H2 stream + // with no :authority / Host). Logged as benign — not ProxyHttpException. + ProxyDiagnostics.ReportBenign(logger, + "HTTP/3 Alt-Svc capability update skipped due to unparseable request origin.", ex); + } + } + + private void TryUpdateHttp3CapabilityFromResponseCore(SessionEventArgs args) { if (!EnableHttp3) return; @@ -28,6 +44,8 @@ private void TryUpdateHttp3CapabilityFromResponse(SessionEventArgs args) if (altSvc == "clear") { var (clearHost, clearPort) = args.HttpClient.Request.GetOriginHostPort(443); + if (string.IsNullOrEmpty(clearHost)) return; + var clearKey = $"{clearHost}:{clearPort}"; Http3OriginCapabilityCache.Evict(clearKey); // Prevent a late background SVCB completion from undoing the clear. diff --git a/src/Titanium.Web.Proxy/Http/Request.cs b/src/Titanium.Web.Proxy/Http/Request.cs index e8464c38e..bd70fa11d 100644 --- a/src/Titanium.Web.Proxy/Http/Request.cs +++ b/src/Titanium.Web.Proxy/Http/Request.cs @@ -119,6 +119,7 @@ public override bool HasBody /// /// Origin host/port from or the Host header — no alloc. /// Falls back to only for absolute-form targets with neither field set. + /// Never throws: malformed / empty authority yields ("", defaultPort). /// internal (string Host, int Port) GetOriginHostPort(int defaultPort) { @@ -131,8 +132,18 @@ public override bool HasBody AuthorityParser.TryParse(header, defaultPort, out host, out port)) return (host, port); - var uri = RequestUri; - return (uri.Host, uri.Port > 0 ? uri.Port : defaultPort); + try + { + var uri = RequestUri; + if (!string.IsNullOrEmpty(uri.Host)) + return (uri.Host, uri.Port > 0 ? uri.Port : defaultPort); + } + catch (UriFormatException) + { + // Relative URL / empty authority — callers treat empty host as a no-op. + } + + return (string.Empty, defaultPort); } /// diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/AlpnNegotiation.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/AlpnNegotiation.cs new file mode 100644 index 000000000..6e44d27d4 --- /dev/null +++ b/src/Titanium.Web.Proxy/Network/TcpConnection/AlpnNegotiation.cs @@ -0,0 +1,56 @@ +using System; +using System.ComponentModel; +using System.Security.Authentication; + +namespace Titanium.Web.Proxy.Network.Tcp; + +/// +/// Detects TLS ALPN negotiation failures so they are not mistaken for TLS-version problems +/// that warrant a protocol downgrade retry. +/// +internal static class AlpnNegotiation +{ + /// SEC_E_NO_APPLICATION_PROTOCOL — no common ALPN between client and server. + internal const int SecENoApplicationProtocol = unchecked((int)0x80090367); + + /// + /// Returns when (or any inner exception) + /// indicates ALPN application-protocol negotiation failed. + /// + internal static bool IsAlpnNegotiationFailure(Exception? error) + { + for (Exception? e = error; e != null; e = e.InnerException) + { + if (e is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + if (IsAlpnNegotiationFailure(inner)) + return true; + } + + continue; + } + + if (e is Win32Exception win32 && win32.NativeErrorCode == SecENoApplicationProtocol) + return true; + + // Some runtimes surface the Win32 code only on HResult. + if (e.HResult == SecENoApplicationProtocol) + return true; + + if (e.Message.Contains("No common application protocol", StringComparison.OrdinalIgnoreCase) || + e.Message.Contains("Application protocol negotiation failed", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + /// + /// True when an should attempt a TLS-version downgrade + /// retry (legacy gate). ALPN mismatches must not enter that path. + /// + internal static bool ShouldAttemptTlsVersionDowngrade(AuthenticationException ex) => + !IsAlpnNegotiationFailure(ex); +} diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs index 3468a89a4..b077bff0a 100644 --- a/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs +++ b/src/Titanium.Web.Proxy/Network/TcpConnection/Ipv6UnreachableSoftSkip.cs @@ -13,7 +13,7 @@ namespace Titanium.Web.Proxy.Network.Tcp; internal static class Ipv6UnreachableSoftSkip { internal const int DefaultStrikeThreshold = 1; - internal static readonly TimeSpan DefaultTtl = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(5); private static int consecutiveIpv6Unreachable; private static long skipUntilUnixMs; // 0 = not skipping diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs index 26a0d7a2b..bb96a12f1 100644 --- a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs +++ b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs @@ -1126,7 +1126,8 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey, goto retry; // NOSONAR S907 -- TLS compatibility fallback must restart the complete connection attempt. } catch (AuthenticationException ex) when (ex.HResult == unchecked((int)0x80131501) && retry && - enabledSslProtocols >= SslProtocols.Tls11) // NOSONAR S4423 - legacy fallback gate + enabledSslProtocols >= SslProtocols.Tls11 && // NOSONAR S4423 - legacy fallback gate + AlpnNegotiation.ShouldAttemptTlsVersionDowngrade(ex)) { if (stream != null) await stream.DisposeAsync(); tcpServerSocket?.Close(); @@ -1146,6 +1147,16 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey, ProxyMetrics.PoolDowngraded(); goto retry; // NOSONAR S907 -- TLS compatibility fallback must restart the complete connection attempt. } + catch (AuthenticationException ex) when (AlpnNegotiation.IsAlpnNegotiationFailure(ex)) + { + // h2-only (or other ALPN) mismatch is not a TLS-version problem — fail fast so + // NegotiateHttp2Async can treat the probe as "no HTTP/2" without multi-second thrash. + if (stream != null) await stream.DisposeAsync(); + tcpServerSocket?.Close(); + ProxyDiagnostics.ReportBenign(proxyServer.Logger, + "TcpConnectionFactory ALPN negotiation rejected by origin; rethrowing without TLS downgrade", ex); + throw; + } #pragma warning restore SYSLIB0039 catch (Exception ex) { diff --git a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt index 43373b98a..02dc461dd 100644 --- a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt +++ b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt @@ -22,6 +22,7 @@ Titanium.Web.Proxy.Network.CertificateKeyAlgorithm.Rsa2048 = 0 -> Titanium.Web.P Titanium.Web.Proxy.Network.CertificateManager.LeafCertificateKeyAlgorithm.get -> Titanium.Web.Proxy.Network.CertificateKeyAlgorithm Titanium.Web.Proxy.Network.CertificateManager.LeafCertificateKeyAlgorithm.set -> void Titanium.Web.Proxy.Network.CertificateManager.ApplyFastColdStartLeafSettings() -> void +static Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory() -> string! static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.get -> int static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.set -> void Titanium.Web.Proxy.Options.ProxyResourceLimits.MaxCertificateDiskCacheEntries.get -> int? diff --git a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs index 1ad272d27..5746a2e90 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs @@ -30,6 +30,7 @@ public class AutomationIdCoverageHeadlessTests "MenuToggleSystemProxy", "MenuInstallCa", "MenuRemoveCa", + "MenuRotateCa", "MenuExportCa", "MenuDeviceCa", "MenuLoopbackExempt", diff --git a/tests/Titanium.Inspector.Tests/RotateRootCaTests.cs b/tests/Titanium.Inspector.Tests/RotateRootCaTests.cs new file mode 100644 index 000000000..62ef47fbf --- /dev/null +++ b/tests/Titanium.Inspector.Tests/RotateRootCaTests.cs @@ -0,0 +1,175 @@ +using System; +using System.IO; +using System.Net; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows.Input; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Inspector.Services; +using Titanium.Inspector.ViewModels; + +namespace Titanium.Inspector.Tests; + +[TestClass] +public class RotateRootCaTests +{ + [TestMethod] + public async Task RotateCa_Cancel_DoesNotChangePfx() + { + var dir = Path.Combine(Path.GetTempPath(), "ti-rot-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + using var interception = new InterceptionService { UseInMemoryTrustState = true }; + OverrideRootPfx(interception, Path.Combine(dir, "rootCert.pfx")); + await interception.StartAsync(IPAddress.Loopback, 0); + var before = interception.RootCertificate!.Thumbprint; + + var dialogs = new ScriptedInspectorDialogs { RotateRootCaResult = false }; + var settings = new SettingsService(Path.Combine(dir, "settings.json")); + var registry = new SessionRegistry(); + var vm = new MainWindowViewModel( + new SessionStreamBuffer(registry), + registry, + new UpdateService(settings), + settings, + interception, + dialogs); + + await ExecuteAsync(vm.RotateCaCommand); + Assert.AreEqual(1, dialogs.RotateRootCaCalls); + Assert.AreEqual(before, interception.RootCertificate!.Thumbprint); + StringAssert.Contains(vm.StatusText, "cancelled"); + interception.EnsureShutdown(); + } + finally + { + try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* best-effort */ } + } + } + + [TestMethod] + public async Task RotateCa_Accept_ChangesThumbprintAndClearsLocalCrts() + { + var dir = Path.Combine(Path.GetTempPath(), "ti-rot2-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + using var interception = new InterceptionService { UseInMemoryTrustState = true }; + OverrideRootPfx(interception, Path.Combine(dir, "rootCert.pfx")); + await interception.StartAsync(IPAddress.Loopback, 0); + var before = interception.RootCertificate!.Thumbprint; + Directory.CreateDirectory(Path.Combine(dir, "crts")); + File.WriteAllText(Path.Combine(dir, "crts", "junk.pfx"), "x"); + + var dialogs = new ScriptedInspectorDialogs + { + RotateRootCaResult = true, + InstallRootCaResult = false + }; + var settings = new SettingsService(Path.Combine(dir, "settings.json")); + var registry = new SessionRegistry(); + var vm = new MainWindowViewModel( + new SessionStreamBuffer(registry), + registry, + new UpdateService(settings), + settings, + interception, + dialogs); + + await ExecuteAsync(vm.RotateCaCommand); + Assert.AreNotEqual(before, interception.RootCertificate!.Thumbprint); + Assert.IsFalse(Directory.Exists(Path.Combine(dir, "crts"))); + Assert.IsTrue(File.Exists(Path.Combine(dir, "rootCert.pfx"))); + interception.EnsureShutdown(); + } + finally + { + try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* best-effort */ } + } + } + + [TestMethod] + public async Task LegacySharedCrts_PruneOnce_IsIdempotentOnStart() + { + var dir = Path.Combine(Path.GetTempPath(), "ti-leg-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var shared = Path.Combine(dir, "shared-crts"); + Directory.CreateDirectory(shared); + File.WriteAllText(Path.Combine(shared, "old.pfx"), "x"); + try + { + using var interception = new InterceptionService + { + UseInMemoryTrustState = true, + LegacyCrtsTestRoot = dir + }; + OverrideRootPfx(interception, Path.Combine(dir, "rootCert.pfx")); + await interception.StartAsync(IPAddress.Loopback, 0); + Assert.IsFalse(Directory.Exists(shared), "first Start should prune shared crts"); + Assert.IsTrue(File.Exists(Path.Combine(dir, "legacy-shared-crts-cleared"))); + + Directory.CreateDirectory(shared); + File.WriteAllText(Path.Combine(shared, "again.pfx"), "y"); + interception.EnsureShutdown(); + + using var interception2 = new InterceptionService + { + UseInMemoryTrustState = true, + LegacyCrtsTestRoot = dir + }; + OverrideRootPfx(interception2, Path.Combine(dir, "rootCert.pfx")); + await interception2.StartAsync(IPAddress.Loopback, 0); + Assert.IsTrue(Directory.Exists(shared), "marker present → Start must not prune again"); + interception2.EnsureShutdown(); + } + finally + { + try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* best-effort */ } + } + } + + [TestMethod] + public async Task Rotate_AlwaysPrunesSharedCrtsEvenWithMarker() + { + var dir = Path.Combine(Path.GetTempPath(), "ti-leg2-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "legacy-shared-crts-cleared"), "already"); + var shared = Path.Combine(dir, "shared-crts"); + Directory.CreateDirectory(shared); + File.WriteAllText(Path.Combine(shared, "old.pfx"), "x"); + try + { + using var interception = new InterceptionService + { + UseInMemoryTrustState = true, + LegacyCrtsTestRoot = dir + }; + OverrideRootPfx(interception, Path.Combine(dir, "rootCert.pfx")); + await interception.StartAsync(IPAddress.Loopback, 0); + Assert.IsTrue(Directory.Exists(shared), "Start with marker keeps shared crts"); + + Assert.IsTrue(interception.RotateRootCertificate(false)); + Assert.IsFalse(Directory.Exists(shared), "Rotate always prunes shared crts"); + interception.EnsureShutdown(); + } + finally + { + try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* best-effort */ } + } + } + + private static void OverrideRootPfx(InterceptionService interception, string path) + { + var field = typeof(InterceptionService).GetField("_rootPfxPath", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field); + field!.SetValue(interception, path); + } + + private static async Task ExecuteAsync(ICommand command) + { + command.Execute(null); + await Task.Delay(150); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/AbsoluteRootLeafCacheTests.cs b/tests/Titanium.Web.Proxy.UnitTests/AbsoluteRootLeafCacheTests.cs new file mode 100644 index 000000000..6360ee3d3 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/AbsoluteRootLeafCacheTests.cs @@ -0,0 +1,172 @@ +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class AbsoluteRootLeafCacheTests +{ + [TestMethod] + public void AbsoluteRoot_SaveAndLoadLeaf_UsesCrtsBesideRoot() + { + if (!RunTime.IsWindows) + Assert.Inconclusive("PKCS#12 Exportable disk-cache characterization is Windows-focused."); + + var rootDir = Path.Combine(Path.GetTempPath(), "twp-abs-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootDir); + var rootPfx = Path.Combine(rootDir, "rootCert.pfx"); + try + { + using var mgr = new CertificateManager(null, null, false, false, false, NullLogger.Instance) + { + CertificateEngine = CertificateEngine.BouncyCastle + }; + Assert.IsTrue(mgr.CreateRootCertificate(false)); + Assert.IsNotNull(mgr.RootCertificate); + + var cache = new DefaultCertificateDiskCache(); + cache.SaveRootCertificate(rootPfx, string.Empty, mgr.RootCertificate); + + using var leaf = mgr.CreateCertificate("leaf.example.com", false)!; + cache.SaveCertificate("leaf.example.com", leaf); + + var leafPath = Path.Combine(rootDir, "crts", "leaf.example.com.pfx"); + Assert.IsTrue(File.Exists(leafPath), "leaf must live under absolute-root/crts/"); + + var loaded = cache.LoadCertificate("leaf.example.com", X509KeyStorageFlags.Exportable); + Assert.IsNotNull(loaded); + Assert.AreEqual(leaf.Thumbprint, loaded!.Thumbprint); + loaded.Dispose(); + + var sharedLeaf = Path.Combine( + DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory(), + "leaf.example.com.pfx"); + Assert.IsFalse(File.Exists(sharedLeaf), "must not write into shared Titanium.Web.Proxy/crts"); + } + finally + { + try { if (Directory.Exists(rootDir)) Directory.Delete(rootDir, true); } catch { /* best-effort */ } + } + } + + [TestMethod] + public void AbsoluteRoot_Clear_DoesNotTouchSharedCrts() + { + if (!RunTime.IsWindows) + Assert.Inconclusive("PKCS#12 Exportable disk-cache characterization is Windows-focused."); + + var rootDir = Path.Combine(Path.GetTempPath(), "twp-abs2-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootDir); + var rootPfx = Path.Combine(rootDir, "rootCert.pfx"); + var shared = DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory(); + Directory.CreateDirectory(shared); + var marker = Path.Combine(shared, "keep-me-" + Guid.NewGuid().ToString("N") + ".pfx"); + File.WriteAllBytes(marker, [1, 2, 3]); + try + { + using var mgr = new CertificateManager(null, null, false, false, false, NullLogger.Instance) + { + CertificateEngine = CertificateEngine.BouncyCastle + }; + Assert.IsTrue(mgr.CreateRootCertificate(false)); + + var cache = new DefaultCertificateDiskCache(); + cache.SaveRootCertificate(rootPfx, string.Empty, mgr.RootCertificate!); + using var leaf = mgr.CreateCertificate("a.example", false)!; + cache.SaveCertificate("a.example", leaf); + Assert.IsTrue(Directory.Exists(Path.Combine(rootDir, "crts"))); + + cache.Clear(); + Assert.IsFalse(Directory.Exists(Path.Combine(rootDir, "crts"))); + Assert.IsTrue(File.Exists(marker), "shared crts marker must survive Clear of absolute-root cache"); + } + finally + { + try { if (File.Exists(marker)) File.Delete(marker); } catch { /* best-effort */ } + try { if (Directory.Exists(rootDir)) Directory.Delete(rootDir, true); } catch { /* best-effort */ } + } + } + + [TestMethod] + public void TwoAbsoluteRoots_DoNotShareLeaves() + { + if (!RunTime.IsWindows) + Assert.Inconclusive("PKCS#12 Exportable disk-cache characterization is Windows-focused."); + + var dirA = Path.Combine(Path.GetTempPath(), "twp-a-" + Guid.NewGuid().ToString("N")); + var dirB = Path.Combine(Path.GetTempPath(), "twp-b-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dirA); + Directory.CreateDirectory(dirB); + try + { + using var mgr = new CertificateManager(null, null, false, false, false, NullLogger.Instance) + { + CertificateEngine = CertificateEngine.BouncyCastle + }; + Assert.IsTrue(mgr.CreateRootCertificate(false)); + + var cacheA = new DefaultCertificateDiskCache(); + cacheA.SaveRootCertificate(Path.Combine(dirA, "rootCert.pfx"), string.Empty, mgr.RootCertificate!); + using var leaf = mgr.CreateCertificate("shared-name.example", false)!; + cacheA.SaveCertificate("shared-name.example", leaf); + + var cacheB = new DefaultCertificateDiskCache(); + cacheB.SaveRootCertificate(Path.Combine(dirB, "rootCert.pfx"), string.Empty, mgr.RootCertificate!); + + Assert.IsTrue(File.Exists(Path.Combine(dirA, "crts", "shared-name.example.pfx"))); + Assert.IsFalse(File.Exists(Path.Combine(dirB, "crts", "shared-name.example.pfx"))); + Assert.IsNull(cacheB.LoadCertificate("shared-name.example", X509KeyStorageFlags.Exportable)); + } + finally + { + try { if (Directory.Exists(dirA)) Directory.Delete(dirA, true); } catch { /* best-effort */ } + try { if (Directory.Exists(dirB)) Directory.Delete(dirB, true); } catch { /* best-effort */ } + } + } +} + +[TestClass] +public class SameCommonNameStoreCandidateTests +{ + [TestMethod] + public void Filter_SameCnDifferentThumbprint_IsOrphan() + { + using var current = CreateSelfSigned("CN=Titanium Root Certificate Authority"); + using var orphan = CreateSelfSigned("CN=Titanium Root Certificate Authority"); + Assert.AreNotEqual(current.Thumbprint, orphan.Thumbprint); + Assert.IsTrue(CertificateManager.IsSameCommonNameStoreCandidate( + orphan, "Titanium Root Certificate Authority", current.Thumbprint)); + Assert.IsFalse(CertificateManager.IsSameCommonNameStoreCandidate( + current, "Titanium Root Certificate Authority", current.Thumbprint)); + } + + [TestMethod] + public void Filter_DifferentCn_NotSelected() + { + using var other = CreateSelfSigned("CN=Other Root"); + Assert.IsFalse(CertificateManager.IsSameCommonNameStoreCandidate( + other, "Titanium Root Certificate Authority", keepThumbprint: null)); + } + + [TestMethod] + public void Filter_NullKeepThumbprint_SelectsMatchingCn() + { + using var cert = CreateSelfSigned("CN=Titanium Root Certificate Authority"); + Assert.IsTrue(CertificateManager.IsSameCommonNameStoreCandidate( + cert, "Titanium Root Certificate Authority", keepThumbprint: null)); + } + + private static X509Certificate2 CreateSelfSigned(string subject) + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new CertificateRequest(subject, rsa, System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/AlpnNegotiationTests.cs b/tests/Titanium.Web.Proxy.UnitTests/AlpnNegotiationTests.cs new file mode 100644 index 000000000..078ddfaaa --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/AlpnNegotiationTests.cs @@ -0,0 +1,57 @@ +using System; +using System.ComponentModel; +using System.Security.Authentication; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Network.Tcp; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class AlpnNegotiationTests +{ + [TestMethod] + public void IsAlpnNegotiationFailure_Win32SecENoApplicationProtocol_True() + { + var inner = new Win32Exception(AlpnNegotiation.SecENoApplicationProtocol); + var ex = new AuthenticationException("Authentication failed, see inner exception.", inner); + Assert.IsTrue(AlpnNegotiation.IsAlpnNegotiationFailure(ex)); + Assert.IsFalse(AlpnNegotiation.ShouldAttemptTlsVersionDowngrade(ex)); + } + + [TestMethod] + public void IsAlpnNegotiationFailure_MessageOnly_True() + { + var ex = new AuthenticationException( + "No common application protocol exists between the client and the server. Application protocol negotiation failed."); + Assert.IsTrue(AlpnNegotiation.IsAlpnNegotiationFailure(ex)); + } + + [TestMethod] + public void IsAlpnNegotiationFailure_PlainAuthException_False() + { + var ex = new AuthenticationException("The remote certificate is invalid according to the validation procedure."); + Assert.IsFalse(AlpnNegotiation.IsAlpnNegotiationFailure(ex)); + Assert.IsTrue(AlpnNegotiation.ShouldAttemptTlsVersionDowngrade(ex)); + } + + [TestMethod] + public void IsAlpnNegotiationFailure_IoException_False() + { + var ex = new System.IO.IOException("Unable to read data from the transport connection."); + Assert.IsFalse(AlpnNegotiation.IsAlpnNegotiationFailure(ex)); + } + + [TestMethod] + public void IsAlpnNegotiationFailure_AggregateWrappingWin32_True() + { + var inner = new Win32Exception(AlpnNegotiation.SecENoApplicationProtocol); + var agg = new AggregateException(new AuthenticationException("fail", inner)); + Assert.IsTrue(AlpnNegotiation.IsAlpnNegotiationFailure(agg)); + } + + [TestMethod] + public void IsAlpnNegotiationFailure_Null_False() + { + Assert.IsFalse(AlpnNegotiation.IsAlpnNegotiationFailure(null)); + } +} diff --git a/tests/Titanium.Web.Proxy.UnitTests/Ipv6UnreachableSoftSkipTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Ipv6UnreachableSoftSkipTests.cs index 20f447169..2195812cb 100644 --- a/tests/Titanium.Web.Proxy.UnitTests/Ipv6UnreachableSoftSkipTests.cs +++ b/tests/Titanium.Web.Proxy.UnitTests/Ipv6UnreachableSoftSkipTests.cs @@ -1,6 +1,7 @@ using System; using System.Net; using System.Net.Sockets; +using System.Threading; using Microsoft.VisualStudio.TestTools.UnitTesting; using Titanium.Web.Proxy.Network.Tcp; @@ -12,6 +13,39 @@ public class Ipv6UnreachableSoftSkipTests [TestInitialize] public void Reset() => Ipv6UnreachableSoftSkip.ResetForTests(); + [TestMethod] + public void DefaultTtl_IsFiveMinutes() + { + Assert.AreEqual(TimeSpan.FromMinutes(5), Ipv6UnreachableSoftSkip.DefaultTtl); + } + + [TestMethod] + public void IsIpv6Unreachable_ClassifiesUnreachableFamily() + { + Assert.IsTrue(Ipv6UnreachableSoftSkip.IsIpv6Unreachable( + new SocketException((int)SocketError.NetworkUnreachable))); + Assert.IsTrue(Ipv6UnreachableSoftSkip.IsIpv6Unreachable( + new SocketException((int)SocketError.HostUnreachable))); + Assert.IsTrue(Ipv6UnreachableSoftSkip.IsIpv6Unreachable( + new SocketException((int)SocketError.NetworkDown))); + Assert.IsTrue(Ipv6UnreachableSoftSkip.IsIpv6Unreachable( + new SocketException((int)SocketError.AddressNotAvailable))); + Assert.IsFalse(Ipv6UnreachableSoftSkip.IsIpv6Unreachable( + new SocketException((int)SocketError.ConnectionRefused))); + } + + [TestMethod] + public void ExplicitShortTtl_ExpiresSkipWindow() + { + var v6 = IPAddress.Parse("2001:db8::1"); + var unreachable = new SocketException((int)SocketError.NetworkUnreachable); + Ipv6UnreachableSoftSkip.RecordAttemptFailure(v6, unreachable, enabled: true, + ttl: TimeSpan.FromMilliseconds(1)); + Assert.IsTrue(Ipv6UnreachableSoftSkip.IsSkipping()); + Thread.Sleep(30); + Assert.IsFalse(Ipv6UnreachableSoftSkip.IsSkipping(ttl: TimeSpan.FromMilliseconds(1))); + } + [TestMethod] public void FilterIfSkipping_WhenNotArmed_ReturnsOriginal() { diff --git a/tests/Titanium.Web.Proxy.UnitTests/RequestOriginHostPortTests.cs b/tests/Titanium.Web.Proxy.UnitTests/RequestOriginHostPortTests.cs new file mode 100644 index 000000000..2dea31062 --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/RequestOriginHostPortTests.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Models; + +namespace Titanium.Web.Proxy.UnitTests; + +[TestClass] +public class RequestOriginHostPortTests +{ + [TestMethod] + public void PathOnly_NoHostNoAuthority_ReturnsEmptyHost() + { + var request = new Request { Method = "GET", RequestUriString = "/" }; + var (host, port) = request.GetOriginHostPort(443); + Assert.AreEqual(string.Empty, host); + Assert.AreEqual(443, port); + } + + [TestMethod] + public void PathOnly_WithHostHeader_ParsesHost() + { + var request = new Request { Method = "GET", RequestUriString = "/x" }; + request.Headers.AddHeader("Host", "example.com"); + var (host, port) = request.GetOriginHostPort(443); + Assert.AreEqual("example.com", host); + Assert.AreEqual(443, port); + } + + [TestMethod] + public void PathOnly_WithHostAndPort_ParsesBoth() + { + var request = new Request { Method = "GET", RequestUriString = "/x" }; + request.Headers.AddHeader("Host", "example.com:8443"); + var (host, port) = request.GetOriginHostPort(443); + Assert.AreEqual("example.com", host); + Assert.AreEqual(8443, port); + } + + [TestMethod] + public void AuthorityWithPort_PreferredOverHost() + { + var request = new Request + { + Method = "GET", + RequestUriString = "/", + Authority = "origin.example:9443".GetByteString() + }; + request.Headers.AddHeader("Host", "ignored.example"); + var (host, port) = request.GetOriginHostPort(443); + Assert.AreEqual("origin.example", host); + Assert.AreEqual(9443, port); + } + + [TestMethod] + public void AuthorityBare_UsesDefaultPort() + { + var request = new Request + { + Method = "GET", + RequestUriString = "/", + Authority = "origin.example".GetByteString() + }; + var (host, port) = request.GetOriginHostPort(443); + Assert.AreEqual("origin.example", host); + Assert.AreEqual(443, port); + } + + [TestMethod] + public void AbsoluteUri_ParsesHostAndPort() + { + var request = new Request + { + Method = "GET", + RequestUriString = "https://uri.example:9443/x" + }; + var (host, port) = request.GetOriginHostPort(443); + Assert.AreEqual("uri.example", host); + Assert.AreEqual(9443, port); + } + + [TestMethod] + public void GarbageHostFallingThrough_DoesNotThrow() + { + var request = new Request { Method = "GET", RequestUriString = "/" }; + // Unbracketed multi-colon host fails AuthorityParser → URI fallback on "https://" alone. + request.Headers.AddHeader("Host", ":::bad"); + var (host, port) = request.GetOriginHostPort(443); + Assert.AreEqual(string.Empty, host); + Assert.AreEqual(443, port); + } + + [TestMethod] + public void BracketedIpv6Host_Parses() + { + var request = new Request { Method = "GET", RequestUriString = "/" }; + request.Headers.AddHeader("Host", "[2001:db8::1]:443"); + var (host, port) = request.GetOriginHostPort(80); + Assert.AreEqual("2001:db8::1", host); + Assert.AreEqual(443, port); + } +} diff --git a/website/docs/inspector.md b/website/docs/inspector.md index 69d13cb8a..0bcee53a7 100644 --- a/website/docs/inspector.md +++ b/website/docs/inspector.md @@ -32,7 +32,9 @@ Capture menu latching options (**Capturing**, **Decrypt HTTPS**, **System proxy* The status strip keeps command feedback on the left and a live **Sessions: N** count on the right, so capture traffic does not wipe tips or export paths. -**Install root CA (current user)** trusts the MITM CA on this PC. **Device CA setup…** opens a dialog with steps for phones/other devices and can **Export CA** from there (or use **Export root CA…** on the Capture menu). +**Install root CA (current user)** trusts the MITM CA on this PC. **Remove root CA** clears every same-name Titanium root in the current-user Trusted Root store (including orphans from earlier installs). **Rotate root CA…** mints a new private key, clears this install’s leaf certificate cache (next to `%AppData%\TitaniumInspector\rootCert.pfx`), removes same-name trusted roots, and prompts to reinstall trust. **Device CA setup…** opens a dialog with steps for phones/other devices and can **Export CA** from there (or use **Export root CA…** on the Capture menu). + +Leaf certificates for Inspector are stored under `%AppData%\TitaniumInspector\crts\` (beside the root PFX), not under the shared `%LocalAppData%\Titanium.Web.Proxy\crts` folder used by the library default. On first start after upgrade (and on every Rotate), Inspector best-effort deletes that legacy shared `crts` folder; it never deletes a shared `rootCert.pfx`. ## Right pane: Inspect vs Tools diff --git a/wiki/Home.md b/wiki/Home.md index 463b2d1d1..a250acd62 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -419,7 +419,7 @@ QUIC endpoint is visible without extra config. | Knob | Balanced default | Speed opt-in | Notes | |---|---|---|---| | `EnableConnectionPool` | `true` | — | Live pool switch; prefer this over unused `ProxyResourceLimits.ConnectionPoolingEnabled`. | -| `EnableIpv6UnreachableSoftSkip` | `true` | disable for strict IPv6 preference | After one IPv6 `NetworkUnreachable`-class Happy Eyeballs failure, skip IPv6 addresses for 30s (filter after address-family interleave). | +| `EnableIpv6UnreachableSoftSkip` | `true` | disable for strict IPv6 preference | After one IPv6 `NetworkUnreachable`-class Happy Eyeballs failure, skip IPv6 addresses for 5 minutes (filter after address-family interleave). | | `MaxCachedConnections` | `128` | raise for high fan-out **per origin** | Live knob on `ProxyServer`. Cap is **per upstream host**, not process-wide. No upper clamp — set `512`/`1024` on large hosts. Keep in sync with `ResourceLimits.MaxCachedConnectionsPerHost` when you replace the snapshot. | | `ProxyEndPoint.MaxCachedConnections` | `null` (use server) | deeper pool for one reverse EP | Optional per-endpoint override applied when that EP owns the session. | | `ResourceLimits.MaxConcurrentStreamsPerConnection` | `256` | raise for heavy H2 fan-in | Replace via `ProxyResourceLimits.Create(...)` — validated positive only, no max ceiling. | diff --git a/wiki/Migration-4.x-to-5.0.md b/wiki/Migration-4.x-to-5.0.md index 59bb9f4f5..08f5c616c 100644 --- a/wiki/Migration-4.x-to-5.0.md +++ b/wiki/Migration-4.x-to-5.0.md @@ -419,7 +419,7 @@ per address (RFC 8305's Connection Attempt Delay) rather than tried one at a tim to complete a TCP (or SOCKS) connect wins; every other in-flight attempt is cancelled and its socket disposed. Failed attempts are result-shaped (one Debug breadcrumb each) rather than faulting the race task. When `EnableIpv6UnreachableSoftSkip` is `true` (the default), a single IPv6 -`NetworkUnreachable`-class failure temporarily omits IPv6 from the race for 30 seconds — operators who +`NetworkUnreachable`-class failure temporarily omits IPv6 from the race for 5 minutes — operators who need strict IPv6 preference on healthy dual-stack can set the knob to `false`. This is a pure latency / exception-overhead improvement with no behavior change for successful connects. ## Connection IDs are monotonic `long` counters, not `Guid` From 9226f22b7b84b6df066ccacbf7d2fce24cca6413 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 19:38:40 -0500 Subject: [PATCH 10/23] fix: avoid Trusted Root yes/no prompts on re-trust Only prune same-CN orphan roots when a new Root thumbprint is installed, and skip Install CA store writes when already trusted. --- .../Services/InterceptionService.cs | 8 ++++ .../Certificates/CertificateManager.cs | 38 ++++++++++++------- .../BindEndpointUxTests.cs | 8 ++-- .../ExportAndSystemProxyCoverageTests.cs | 25 +++++++++--- website/docs/inspector.md | 2 +- 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index 5f22732eb..52c7d85bc 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -380,6 +380,14 @@ public bool InstallRootCertificate(bool machineStore) return true; } + // Already trusted: skip TrustRootCertificate so Windows does not show another + // Trusted Root security dialog (or orphan-removal prompt) on repeated Install CA. + if (IsRootPresentInStore(machineStore)) + { + IsRootTrusted = true; + return true; + } + _proxy.CertificateManager.TrustRootCertificate(machineStore); IsRootTrusted = IsRootPresentInStore(machineStore); return IsRootTrusted; diff --git a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs index 59573096b..877cbbbff 100644 --- a/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs +++ b/src/Titanium.Web.Proxy/Certificates/CertificateManager.cs @@ -645,8 +645,9 @@ internal static bool IsSameCommonNameStoreCandidate( /// /// Removes Root/My store certificates that share . /// When is , the current - /// thumbprint is preserved (orphan cleanup after install). - /// When , every matching CN is removed (Remove CA). + /// thumbprint is preserved (orphan cleanup after a + /// new Root install — not on re-trust of an already-present thumbprint). + /// When , every matching CN is removed (Remove CA / Rotate). /// private void RemoveOrphanedSameCommonNameCertificates(StoreLocation storeLocation, bool keepCurrentThumbprint) { @@ -696,16 +697,18 @@ private void RemoveMatchingCertificates( } /// - /// Make current machine trust the Root Certificate used by this proxy + /// Make current machine trust the Root Certificate used by this proxy. /// - /// - /// - private void InstallCertificate(StoreName storeName, StoreLocation storeLocation) + /// + /// when the certificate was newly added; + /// when it was already present or the install failed. + /// + private bool InstallCertificate(StoreName storeName, StoreLocation storeLocation) { var certificate = RootCertificate; if (certificate == null) throw new InvalidOperationException("Could not install certificate as it is null or empty."); - if (FindCertificates(storeName, storeLocation, certificate.Thumbprint).Count > 0) return; + if (FindCertificates(storeName, storeLocation, certificate.Thumbprint).Count > 0) return false; var x509Store = new X509Store(storeName, storeLocation); @@ -713,6 +716,7 @@ private void InstallCertificate(StoreName storeName, StoreLocation storeLocation { x509Store.Open(OpenFlags.ReadWrite); x509Store.Add(certificate); + return true; } catch (Exception e) { @@ -720,6 +724,7 @@ private void InstallCertificate(StoreName storeName, StoreLocation storeLocation new Exception("Failed to make system trust root certificate " + $" for {storeName}\\{storeLocation} store location. You may need admin rights.", e)); + return false; } finally { @@ -1335,17 +1340,21 @@ public void TrustRootCertificate(bool machineTrusted = false) { // currentUser\personal InstallCertificate(StoreName.My, StoreLocation.CurrentUser); - // currentUser\Root - InstallCertificate(StoreName.Root, StoreLocation.CurrentUser); - RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true); + // currentUser\Root — Windows may show a Trusted Root yes/no security dialog on Add. + var rootAdded = InstallCertificate(StoreName.Root, StoreLocation.CurrentUser); + // Orphan Remove also prompts; only prune when we just installed this thumbprint so + // re-trust / Install CA when already present does not open Root ReadWrite for cleanup. + if (rootAdded) + RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true); if (machineTrusted) { // localMachine\personal InstallCertificate(StoreName.My, StoreLocation.LocalMachine); // localMachine\Root - InstallCertificate(StoreName.Root, StoreLocation.LocalMachine); - RemoveOrphanedSameCommonNameCertificates(StoreLocation.LocalMachine, keepCurrentThumbprint: true); + var machineRootAdded = InstallCertificate(StoreName.Root, StoreLocation.LocalMachine); + if (machineRootAdded) + RemoveOrphanedSameCommonNameCertificates(StoreLocation.LocalMachine, keepCurrentThumbprint: true); } // On macOS/Linux, also trust for SSL in Keychain / NSS so browsers accept MITM. @@ -1369,8 +1378,9 @@ public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) // currentUser\Personal + currentUser\Root (machine elevation is only needed for LocalMachine). InstallCertificate(StoreName.My, StoreLocation.CurrentUser); - InstallCertificate(StoreName.Root, StoreLocation.CurrentUser); - RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true); + var rootAdded = InstallCertificate(StoreName.Root, StoreLocation.CurrentUser); + if (rootAdded) + RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true); if (!RunTime.IsWindows) { diff --git a/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs b/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs index 1492bd0a0..3359a08cb 100644 --- a/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs +++ b/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs @@ -19,7 +19,7 @@ public async Task BindFields_DisabledWhileRunning_EndpointStatusTracksLifecycle( settings.Save(); var recorder = new RecordingSystemProxyController(); - using var interception = new InterceptionService(recorder); + using var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true }; var registry = new SessionRegistry(); var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), @@ -71,7 +71,7 @@ public async Task ToggleInterceptCommand_StartsAndStops() settings.Save(); var recorder = new RecordingSystemProxyController(); - using var interception = new InterceptionService(recorder); + using var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true }; var registry = new SessionRegistry(); var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), @@ -114,7 +114,7 @@ public async Task StopWithSystemProxy_ReenablesOnNextStart() settings.Save(); var recorder = new RecordingSystemProxyController(); - using var interception = new InterceptionService(recorder); + using var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true }; var registry = new SessionRegistry(); var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), @@ -165,7 +165,7 @@ public async Task ManualStart_WithAutoSystemProxyOnStart_EnablesSystemProxy() settings.Save(); var recorder = new RecordingSystemProxyController(); - using var interception = new InterceptionService(recorder); + using var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true }; var registry = new SessionRegistry(); var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), diff --git a/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs b/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs index 5bb7251a6..218531461 100644 --- a/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs +++ b/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs @@ -16,7 +16,10 @@ public async Task ExportCommands_CoverEmptyCancelAndSelectedPaths() var settings = new SettingsService(path); var registry = new SessionRegistry(); var picker = new ScriptedInspectorPathPicker(); - using var interception = new InterceptionService(new RecordingSystemProxyController()); + using var interception = new InterceptionService(new RecordingSystemProxyController()) + { + UseInMemoryTrustState = true, + }; var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), registry, @@ -108,7 +111,10 @@ public async Task ExportHarCommands_ReportFailureWhenPathCannotBeWritten() var settings = new SettingsService(settingsPath); var registry = new SessionRegistry(); var picker = new ScriptedInspectorPathPicker(); - using var interception = new InterceptionService(new RecordingSystemProxyController()); + using var interception = new InterceptionService(new RecordingSystemProxyController()) + { + UseInMemoryTrustState = true, + }; var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), registry, @@ -158,7 +164,10 @@ public async Task StartStopCapture_AndSystemProxyWithoutCapture_AreCovered() settings.Save(); var registry = new SessionRegistry(); - using var interception = new InterceptionService(new RecordingSystemProxyController()); + using var interception = new InterceptionService(new RecordingSystemProxyController()) + { + UseInMemoryTrustState = true, + }; var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), registry, @@ -205,7 +214,10 @@ public async Task CaCommands_Filters_Capturing_AndShutdown_CoverMoreBranches() ElevateRootCaResult = false, DeviceCaSetupResult = false, }; - using var interception = new InterceptionService(new RecordingSystemProxyController()); + using var interception = new InterceptionService(new RecordingSystemProxyController()) + { + UseInMemoryTrustState = true, + }; var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), registry, @@ -346,7 +358,10 @@ public async Task TryAutoStart_WithLaunchPrefs_CoversSystemProxySuccessPath() settings.Save(); var registry = new SessionRegistry(); - using var interception = new InterceptionService(new RecordingSystemProxyController()); + using var interception = new InterceptionService(new RecordingSystemProxyController()) + { + UseInMemoryTrustState = true, + }; var vm = new MainWindowViewModel( new SessionStreamBuffer(registry), registry, diff --git a/website/docs/inspector.md b/website/docs/inspector.md index 0bcee53a7..2f77f0537 100644 --- a/website/docs/inspector.md +++ b/website/docs/inspector.md @@ -32,7 +32,7 @@ Capture menu latching options (**Capturing**, **Decrypt HTTPS**, **System proxy* The status strip keeps command feedback on the left and a live **Sessions: N** count on the right, so capture traffic does not wipe tips or export paths. -**Install root CA (current user)** trusts the MITM CA on this PC. **Remove root CA** clears every same-name Titanium root in the current-user Trusted Root store (including orphans from earlier installs). **Rotate root CA…** mints a new private key, clears this install’s leaf certificate cache (next to `%AppData%\TitaniumInspector\rootCert.pfx`), removes same-name trusted roots, and prompts to reinstall trust. **Device CA setup…** opens a dialog with steps for phones/other devices and can **Export CA** from there (or use **Export root CA…** on the Capture menu). +**Install root CA (current user)** trusts the MITM CA on this PC. On Windows, the OS may show a Trusted Root **Yes/No** security dialog the first time that certificate is added (this is not UAC). Re-installing when the CA is already trusted does not prompt again; orphan same-name roots are cleaned up only when a new thumbprint is installed, or via **Remove** / **Rotate**. **Remove root CA** clears every same-name Titanium root in the current-user Trusted Root store (including orphans from earlier installs). **Rotate root CA…** mints a new private key, clears this install’s leaf certificate cache (next to `%AppData%\TitaniumInspector\rootCert.pfx`), removes same-name trusted roots, and prompts to reinstall trust. **Device CA setup…** opens a dialog with steps for phones/other devices and can **Export CA** from there (or use **Export root CA…** on the Capture menu). Leaf certificates for Inspector are stored under `%AppData%\TitaniumInspector\crts\` (beside the root PFX), not under the shared `%LocalAppData%\Titanium.Web.Proxy\crts` folder used by the library default. On first start after upgrade (and on every Rotate), Inspector best-effort deletes that legacy shared `crts` folder; it never deletes a shared `rootCert.pfx`. From 657c23133625782d551a652df162d77c53c47262 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 19:49:01 -0500 Subject: [PATCH 11/23] ux(inspector): rename Rotate root CA to Clear and reinstall Make the Capture menu and confirm dialog match Install/Remove wording, and align docs. Include leftover ALPN/Alt-Svc test coverage and wiki notes. --- .../Services/IInspectorDialogs.cs | 14 +- .../ViewModels/MainWindowViewModel.cs | 10 +- src/Titanium.Inspector/Views/MainWindow.axaml | 2 +- ...p2OriginCapabilityCacheIntegrationTests.cs | 27 ++++ .../Http3DiscoveryHandlerTests.cs | 131 ++++++++++++++++++ website/docs/inspector.md | 4 +- wiki/Home.md | 3 + wiki/Migration-4.x-to-5.0.md | 15 ++ 8 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 tests/Titanium.Web.Proxy.UnitTests/Http3DiscoveryHandlerTests.cs diff --git a/src/Titanium.Inspector/Services/IInspectorDialogs.cs b/src/Titanium.Inspector/Services/IInspectorDialogs.cs index c7e11b4ae..86414783d 100644 --- a/src/Titanium.Inspector/Services/IInspectorDialogs.cs +++ b/src/Titanium.Inspector/Services/IInspectorDialogs.cs @@ -20,7 +20,7 @@ public interface IInspectorDialogs /// Task ShowDeviceCaSetupAsync(Window? owner, string message); - /// Ask to rotate (regenerate) the Titanium root CA. Returns true if confirmed. + /// Ask to clear and reinstall (regenerate) the Titanium root CA. Returns true if confirmed. Task ConfirmRotateRootCaAsync(Window? owner); /// @@ -68,13 +68,13 @@ public Task ShowDeviceCaSetupAsync(Window? owner, string message) => public Task ConfirmRotateRootCaAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, - "Rotate root CA", - "Generate a new Titanium Inspector root CA private key?\n\n" + + "Clear and reinstall root CA", + "Clear the current Titanium Inspector root CA and create a new one?\n\n" + "• All same-name Titanium roots are removed from the current-user Trusted Root store\n" + - "• Disk leaf certificates for this install are cleared\n" + - "• You must Install root CA again (or enable Decrypt HTTPS) to re-trust\n\n" + - "Stop capture is recommended before rotating.", - accept: "Rotate", + "• Cached site certificates for this install are cleared\n" + + "• You will be asked to trust the new root CA again (or enable Decrypt HTTPS)\n\n" + + "Stop capture is recommended first.", + accept: "Clear and reinstall", cancel: "Cancel", height: 320); diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 36f42824e..b4c6b9f41 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -575,7 +575,7 @@ private async Task RotateCaAsync() var owner = TryGetMainWindow(); if (!await _dialogs.ConfirmRotateRootCaAsync(owner)) { - StatusText = "Rotate root CA cancelled"; + StatusText = "Clear and reinstall root CA cancelled"; return; } @@ -586,7 +586,7 @@ private async Task RotateCaAsync() var ok = _interception.RotateRootCertificate(machineStore: false); if (!ok) { - StatusText = "Rotate root CA failed — see logs"; + StatusText = "Clear and reinstall root CA failed — see logs"; return; } @@ -602,14 +602,14 @@ private async Task RotateCaAsync() StatusText = trusted ? (changed - ? "Root CA rotated and trusted — enable Decrypt HTTPS when ready" + ? "Root CA cleared and reinstalled — enable Decrypt HTTPS when ready" : "Root CA recreate completed and trusted") - : "Root CA rotated but trust failed — use Install root CA or Export CA"; + : "Root CA cleared but trust failed — use Install root CA or Export CA"; return; } StatusText = changed - ? "Root CA rotated — Install root CA (or enable Decrypt HTTPS) to trust the new certificate" + ? "Root CA cleared — Install root CA (or enable Decrypt HTTPS) to trust the new certificate" : "Root CA recreate completed — Install root CA to trust"; } diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml index 06789d7d6..59e881780 100644 --- a/src/Titanium.Inspector/Views/MainWindow.axaml +++ b/src/Titanium.Inspector/Views/MainWindow.axaml @@ -47,7 +47,7 @@ AutomationProperties.AutomationId="MenuToggleSystemProxy" /> - + SendOneRequestOverANewTunnelAsync() "session connection for the second; the second tunnel should have reused the first tunnel's " + "cached capability result instead of probing the origin again."); } + + [TestMethod] + [Timeout(30 * 1000)] + public async Task Http2_ProbeAlpnRejectedByH1OnlyOrigin_DoesNotCacheFalse() + { + using var rawServer = new Http11OnlyOriginServer(CreateOriginCertificate()); + + using var testSuite = new TestSuite(); + var proxy = testSuite.GetProxy(); + proxy.EnableHttp2 = true; + proxy.EnableTcpServerConnectionPrefetch = false; + + using var client = TestHelper.GetHttpClient(proxy.ProxyEndPoints[0].Port); + using var response = await client.GetAsync($"https://localhost:{rawServer.Port}/"); + Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode, + "Auto mode should fall back to HTTP/1.1 after the h2-only ALPN probe is rejected."); + + // NegotiateHttp2Async must not Set(false) on probe exceptions (including ALPN mismatch), + // or every later tunnel would be pinned to h1 for the full TTL. + var capabilityKey = Network.Tcp.TcpConnectionFactory.GetConnectionCacheKey( + "localhost", rawServer.Port, isHttps: true, applicationProtocols: null, + upStreamEndPoint: null, externalProxy: null); + + Assert.IsFalse(proxy.Http2OriginCapabilityCache.TryGet(capabilityKey, out var supported), + "ALPN-rejected h2 probe must leave the capability cache empty (no false entry)."); + Assert.IsFalse(supported); + } } diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http3DiscoveryHandlerTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http3DiscoveryHandlerTests.cs new file mode 100644 index 000000000..da16df89c --- /dev/null +++ b/tests/Titanium.Web.Proxy.UnitTests/Http3DiscoveryHandlerTests.cs @@ -0,0 +1,131 @@ +#pragma warning disable TWP001 // Experimental HTTP/3 API — intentional Alt-Svc coverage +using System; +using System.IO; +using System.Net; +using System.Reflection; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Tcp; + +namespace Titanium.Web.Proxy.UnitTests; + +/// +/// AfterResponse Alt-Svc → bookkeeping must never +/// throw (including path-only origins with no Host/Authority). +/// +[TestClass] +public class Http3DiscoveryHandlerTests +{ + private static readonly BindingFlags PrivateInstance = + BindingFlags.Instance | BindingFlags.NonPublic; + + private static SessionEventArgs MakeSession(ProxyServer proxy) + { + var endPoint = new ExplicitProxyEndPoint(IPAddress.Loopback, 0, false); + var connection = new QuicClientConnection( + proxy, new IPEndPoint(IPAddress.Loopback, 4433), new IPEndPoint(IPAddress.Loopback, 12345)); + var cts = new CancellationTokenSource(); + var clientStream = new HttpClientStream(proxy, connection, Stream.Null, proxy.BufferPool, cts.Token); + return new SessionEventArgs(proxy, endPoint, clientStream, null, cts); + } + + private static void InvokeTryUpdate(ProxyServer proxy, SessionEventArgs session) + { + var method = typeof(ProxyServer).GetMethod("TryUpdateHttp3CapabilityFromResponse", PrivateInstance); + Assert.IsNotNull(method); + method!.Invoke(proxy, [session]); + } + + [TestMethod] + public void AltSvc_ValidH3_CachesCapabilityForOrigin() + { + using var proxy = new ProxyServer(false, false, false) { EnableHttp3 = true }; + var session = MakeSession(proxy); + session.HttpClient.Request.Method = "GET"; + session.HttpClient.Request.RequestUriString = "/"; + session.HttpClient.Request.Headers.AddHeader("Host", "altsvc.example.com"); + session.HttpClient.Response.Headers.AddHeader("Alt-Svc", "h3=\":443\"; ma=86400"); + + InvokeTryUpdate(proxy, session); + + Assert.IsTrue(proxy.Http3OriginCapabilityCache.TryGet("altsvc.example.com:443", out _)); + } + + [TestMethod] + public void AltSvc_Clear_EvictsCachedCapability() + { + using var proxy = new ProxyServer(false, false, false) { EnableHttp3 = true }; + proxy.Http3OriginCapabilityCache.Set("clear.example.com:443"); + Assert.IsTrue(proxy.Http3OriginCapabilityCache.TryGet("clear.example.com:443", out _)); + + var session = MakeSession(proxy); + session.HttpClient.Request.Method = "GET"; + session.HttpClient.Request.RequestUriString = "/"; + session.HttpClient.Request.Headers.AddHeader("Host", "clear.example.com"); + session.HttpClient.Response.Headers.AddHeader("Alt-Svc", "clear"); + + InvokeTryUpdate(proxy, session); + + Assert.IsFalse(proxy.Http3OriginCapabilityCache.TryGet("clear.example.com:443", out _)); + } + + [TestMethod] + public void AltSvc_PathOnlyUnparseableOrigin_DoesNotThrowOrWrite() + { + using var proxy = new ProxyServer(false, false, false) { EnableHttp3 = true }; + var session = MakeSession(proxy); + session.HttpClient.Request.Method = "GET"; + session.HttpClient.Request.RequestUriString = "/"; + // No Host / Authority — GetOriginHostPort returns empty host. + session.HttpClient.Response.Headers.AddHeader("Alt-Svc", "h3=\":443\"; ma=86400"); + + InvokeTryUpdate(proxy, session); + + Assert.IsFalse(proxy.Http3OriginCapabilityCache.TryGet(":443", out _)); + Assert.IsFalse(proxy.Http3OriginCapabilityCache.TryGet(":80", out _)); + } + + [TestMethod] + public void AltSvc_Clear_UnparseableOrigin_DoesNotThrow() + { + using var proxy = new ProxyServer(false, false, false) { EnableHttp3 = true }; + var session = MakeSession(proxy); + session.HttpClient.Request.Method = "GET"; + session.HttpClient.Request.RequestUriString = "/"; + session.HttpClient.Response.Headers.AddHeader("Alt-Svc", "clear"); + + InvokeTryUpdate(proxy, session); + } + + [TestMethod] + public void AltSvc_EnableHttp3False_IsNoOp() + { + using var proxy = new ProxyServer(false, false, false) { EnableHttp3 = false }; + var session = MakeSession(proxy); + session.HttpClient.Request.Method = "GET"; + session.HttpClient.Request.RequestUriString = "/"; + session.HttpClient.Request.Headers.AddHeader("Host", "off.example.com"); + session.HttpClient.Response.Headers.AddHeader("Alt-Svc", "h3=\":443\"; ma=86400"); + + InvokeTryUpdate(proxy, session); + + Assert.IsFalse(proxy.Http3OriginCapabilityCache.TryGet("off.example.com:443", out _)); + } + + [TestMethod] + public void AltSvc_MissingHeader_IsNoOp() + { + using var proxy = new ProxyServer(false, false, false) { EnableHttp3 = true }; + var session = MakeSession(proxy); + session.HttpClient.Request.Method = "GET"; + session.HttpClient.Request.RequestUriString = "/"; + session.HttpClient.Request.Headers.AddHeader("Host", "none.example.com"); + + InvokeTryUpdate(proxy, session); + + Assert.IsFalse(proxy.Http3OriginCapabilityCache.TryGet("none.example.com:443", out _)); + } +} diff --git a/website/docs/inspector.md b/website/docs/inspector.md index 2f77f0537..d088d68a4 100644 --- a/website/docs/inspector.md +++ b/website/docs/inspector.md @@ -32,9 +32,9 @@ Capture menu latching options (**Capturing**, **Decrypt HTTPS**, **System proxy* The status strip keeps command feedback on the left and a live **Sessions: N** count on the right, so capture traffic does not wipe tips or export paths. -**Install root CA (current user)** trusts the MITM CA on this PC. On Windows, the OS may show a Trusted Root **Yes/No** security dialog the first time that certificate is added (this is not UAC). Re-installing when the CA is already trusted does not prompt again; orphan same-name roots are cleaned up only when a new thumbprint is installed, or via **Remove** / **Rotate**. **Remove root CA** clears every same-name Titanium root in the current-user Trusted Root store (including orphans from earlier installs). **Rotate root CA…** mints a new private key, clears this install’s leaf certificate cache (next to `%AppData%\TitaniumInspector\rootCert.pfx`), removes same-name trusted roots, and prompts to reinstall trust. **Device CA setup…** opens a dialog with steps for phones/other devices and can **Export CA** from there (or use **Export root CA…** on the Capture menu). +**Install root CA (current user)** trusts the MITM CA on this PC. On Windows, the OS may show a Trusted Root **Yes/No** security dialog the first time that certificate is added (this is not UAC). Re-installing when the CA is already trusted does not prompt again; orphan same-name roots are cleaned up only when a new thumbprint is installed, or via **Remove** / **Clear and reinstall**. **Remove root CA** clears every same-name Titanium root in the current-user Trusted Root store (including orphans from earlier installs). **Clear and reinstall root CA…** mints a new private key, clears this install’s leaf certificate cache (next to `%AppData%\TitaniumInspector\rootCert.pfx`), removes same-name trusted roots, and prompts to reinstall trust. **Device CA setup…** opens a dialog with steps for phones/other devices and can **Export CA** from there (or use **Export root CA…** on the Capture menu). -Leaf certificates for Inspector are stored under `%AppData%\TitaniumInspector\crts\` (beside the root PFX), not under the shared `%LocalAppData%\Titanium.Web.Proxy\crts` folder used by the library default. On first start after upgrade (and on every Rotate), Inspector best-effort deletes that legacy shared `crts` folder; it never deletes a shared `rootCert.pfx`. +Leaf certificates for Inspector are stored under `%AppData%\TitaniumInspector\crts\` (beside the root PFX), not under the shared `%LocalAppData%\Titanium.Web.Proxy\crts` folder used by the library default. On first start after upgrade (and on every clear/reinstall), Inspector best-effort deletes that legacy shared `crts` folder; it never deletes a shared `rootCert.pfx`. ## Right pane: Inspect vs Tools diff --git a/wiki/Home.md b/wiki/Home.md index a250acd62..2611e87be 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -92,6 +92,9 @@ Example apps live under [`examples/`](https://github.com/justcoding121/titanium- Titanium Inspector screenshot +Inspector stores its MITM root beside `%AppData%\TitaniumInspector\rootCert.pfx` and leaf certs in a sibling `crts\` folder (not the shared `%LocalAppData%\Titanium.Web.Proxy\crts` default). Use **Clear and reinstall root CA…** to mint a new root, clear local leaves, and remove same-name Trusted Root entries; on first start after upgrade (and on every clear/reinstall) Inspector best-effort deletes the legacy shared `crts` folder only — never a shared `rootCert.pfx`. Windows may show a Trusted Root Yes/No security dialog the first time a new root is installed (not UAC). + +Outbound HTTP/2 probes that fail ALPN (`SEC_E_NO_APPLICATION_PROTOCOL` / “No common application protocol”) are treated as “origin has no h2” without a TLS-version downgrade thrash, and a failed probe is **not** cached as a permanent “no HTTP/2” result. **Basic console example** — compact per-request traffic tape: Basic console proxy screenshot diff --git a/wiki/Migration-4.x-to-5.0.md b/wiki/Migration-4.x-to-5.0.md index 08f5c616c..1c55fb317 100644 --- a/wiki/Migration-4.x-to-5.0.md +++ b/wiki/Migration-4.x-to-5.0.md @@ -104,6 +104,21 @@ read the CA private key, or on some deployments replace the leaf-certificate cac - If your deployment pins or backs up the certificate path (Docker volumes, provisioning scripts, documentation), update those paths to the new location. +**Inspector (7.0+):** when `CertificateManager.PfxFilePath` is an absolute path (Inspector’s +`%AppData%\TitaniumInspector\rootCert.pfx`), generated leaf PKCS#12 files are stored under a sibling +`crts\` directory — not under the shared `%LocalAppData%\Titanium.Web.Proxy\crts` folder. **Clear and +reinstall root CA…** remints the root, clears that local leaf cache, and removes same-CN Trusted Root +entries. On first Inspector start after upgrade (marker file) and on every clear/reinstall, Inspector +best-effort deletes only the legacy shared `crts` directory; it never deletes a shared `rootCert.pfx`. +Orphan same-CN roots are pruned when a **new** root thumbprint is installed, or via Remove / Clear and +reinstall — not on every re-trust of an already-present CA (avoids repeated Windows Trusted Root Yes/No +prompts). + +**HTTP/2 ALPN probes:** an origin that rejects h2-only ALPN (`SEC_E_NO_APPLICATION_PROTOCOL`) no +longer triggers a TLS-version downgrade retry loop. Failed probes are not written into +`Http2OriginCapabilityCache` as `Supported=false`, so a later successful h2 origin is not pinned to +HTTP/1.1 for the cache TTL. + --- ## Ambiguous HTTP/1 framing is now rejected From de8e18942a3a5e0c339e1b8c4c2ed893e2f1b2cd Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 19:56:51 -0500 Subject: [PATCH 12/23] fix: green UI/Plus tests and bump product to 7.0.1 for beta cut Align StatusText and RequiredAbstractionsionsVersion asserts with current develop, and ship the next beta as 7.0.1 because 7.0.0-beta NuGet is already published. --- README.md | 6 +++--- src/Titanium.Cli/Titanium.Cli.csproj | 2 +- src/Titanium.Inspector/Services/SessionArchive.cs | 2 +- src/Titanium.Inspector/Titanium.Inspector.csproj | 2 +- src/Titanium.Plus/Titanium.Plus.csproj | 2 +- .../Titanium.Web.Proxy.Abstractions.csproj | 2 +- .../Titanium.Web.Proxy.Configuration.csproj | 2 +- src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs | 4 ++-- src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj | 2 +- tests/Titanium.E2E.Tests/CliCommandE2ETests.cs | 4 ++-- tests/Titanium.E2E.Tests/InspectorHeadlessUiE2ETests.cs | 5 +++-- tests/Titanium.Plus.Tests/PlusModuleTests.cs | 2 +- tools/RpsLoadProbe/PERF-GATES.md | 2 +- website/docs/inspector.md | 4 ++-- website/docs/install.md | 4 ++-- website/docs/library.md | 2 +- website/download.md | 4 ++-- 17 files changed, 26 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 5118309e2..6ad31fbe5 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ A lightweight, high-performance HTTP(S) proxy — reverse / edge CLI, desktop In | **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack: `run`, `test`, `version`, `update` | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#cli) | | **Titanium Inspector** | Desktop MITM debugger (session grid, inspectors, AutoResponder, breakpoints, HAR) | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#inspector) | | **Titanium.Plus** | Optional advanced features: control plane, ops, observability, and dashboard | After installing CLI, run `titanium update --plus` | -| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy/7.0.0-beta) (`dotnet add package Titanium.Web.Proxy --prerelease`) | +| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy/7.0.1-beta) (`dotnet add package Titanium.Web.Proxy --prerelease`) | CLI and Plus target reverse-proxy / edge workloads (routing, load balancing, health, discovery) on Windows, Linux, and macOS. Inspector is the MITM debugging product. The Core library is the embed path for .NET. Requires .NET 10 or later. @@ -67,7 +67,7 @@ On Windows, **winget is stable-only**: winget install justcoding121.TitaniumCli ``` -For **beta**, download self-contained zips from [Download](https://titaniumproxy.com/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when a product release includes `Titanium.Cli-*.zip` assets (e.g. `v7.0.0-beta`). Extract and run: +For **beta**, download self-contained zips from [Download](https://titaniumproxy.com/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when a product release includes `Titanium.Cli-*.zip` assets (e.g. `v7.0.1-beta`). Extract and run: ```shell titanium run -c twp.yaml @@ -82,7 +82,7 @@ Optional Plus: run `titanium update --plus` (add `--channel beta` for prerelease ### Titanium Inspector -Prefer [Download](https://titaniumproxy.com/download). On Windows, winget id `justcoding121.TitaniumInspector` is **stable-only**; MSI / portable zip for beta come from the product `v*` release (e.g. `v7.0.0-beta`). Start interception from the Capture menu, install the root CA, then toggle system proxy. +Prefer [Download](https://titaniumproxy.com/download). On Windows, winget id `justcoding121.TitaniumInspector` is **stable-only**; MSI / portable zip for beta come from the product `v*` release (e.g. `v7.0.1-beta`). Start interception from the Capture menu, install the root CA, then toggle system proxy. ## Quick start diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index c5910282c..3afa41f7d 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,7 +7,7 @@ latest enable false - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index 5fbec2a3b..003e1dfea 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -17,7 +17,7 @@ public static async Task ExportHarAsync(IEnumerable sessions, s log = new { version = "1.2", - creator = new { name = "Titanium Inspector", version = "7.0.0" }, + creator = new { name = "Titanium Inspector", version = "7.0.1" }, entries, }, }; diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index 8d1903599..443b7bf6f 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE diff --git a/src/Titanium.Plus/Titanium.Plus.csproj b/src/Titanium.Plus/Titanium.Plus.csproj index 688cb7eff..8aed67e74 100644 --- a/src/Titanium.Plus/Titanium.Plus.csproj +++ b/src/Titanium.Plus/Titanium.Plus.csproj @@ -7,7 +7,7 @@ enable True StrongNameKey.snk - 7.0.0 + 7.0.1 Jehonathan Thomas Titanium 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 bc4c5d0ce..b8d8560ed 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 @@ enable True StrongNameKey.snk - 7.0.0 + 7.0.1 Jehonathan Thomas Shared contracts for Titanium Web Proxy routing, clusters, middleware, and plugins. MIT 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 fc57a8ff6..f092aa7fd 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 @@ enable True StrongNameKey.snk - 7.0.0 + 7.0.1 Jehonathan Thomas YAML/JSON configuration binding for Titanium Web Proxy CLI and reverse-proxy documents. MIT diff --git a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs index 570d2a549..fc825435a 100644 --- a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs +++ b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs @@ -65,5 +65,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.0.0")] -[assembly: AssemblyFileVersion("7.0.0.0")] +[assembly: AssemblyVersion("7.0.1.0")] +[assembly: AssemblyFileVersion("7.0.1.0")] diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj index 02d1d27f8..357563c37 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.0 + 7.0.1