From e88c297d70f6e19baa86f611130f9be06b12763f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 16:22:21 -0700
Subject: [PATCH 01/63] chore(deps): bump actions/deploy-pages from 4 to 5
(#1038)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)
---
updated-dependencies:
- dependency-name: actions/deploy-pages
dependency-version: '5'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jehonathan Thomas
---
.github/workflows/deploy-website.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
index fb8b6a281..6daa39fd8 100644
--- a/.github/workflows/deploy-website.yml
+++ b/.github/workflows/deploy-website.yml
@@ -134,4 +134,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
From 074e6b313b2678e1a0bd70c57746af37cb1dc5db Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 16:22:26 -0700
Subject: [PATCH 02/63] chore(deps): bump actions/setup-node from 4 to 7
(#1040)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)
---
updated-dependencies:
- dependency-name: actions/setup-node
dependency-version: '7'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jehonathan Thomas
---
.github/workflows/deploy-website.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
index 6daa39fd8..f89208856 100644
--- a/.github/workflows/deploy-website.yml
+++ b/.github/workflows/deploy-website.yml
@@ -38,7 +38,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
From 476c5931f61530d4b7a1b2dff9134840e92297b3 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 16:42:07 -0700
Subject: [PATCH 03/63] chore(deps): bump azure/login from 2 to 3 (#1039)
Bumps [azure/login](https://github.com/azure/login) from 2 to 3.
- [Release notes](https://github.com/azure/login/releases)
- [Commits](https://github.com/azure/login/compare/v2...v3)
---
updated-dependencies:
- dependency-name: azure/login
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jehonathan Thomas
---
.github/workflows/release.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d90401edd..59f9eeefc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -211,7 +211,7 @@ jobs:
./tools/packaging/bundle-http3-native.ps1 -Rid $rid -PublishDir $out
- name: Azure login (OIDC)
if: matrix.rid == 'win-x64'
- uses: azure/login@v2
+ uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -398,7 +398,7 @@ jobs:
}
- name: Azure login (OIDC)
if: matrix.rid == 'win-x64'
- uses: azure/login@v2
+ uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
From d1ef4bdbcfe3e271d85c5e4df6cf04dbe03d13cb Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 16:42:11 -0700
Subject: [PATCH 04/63] chore(deps): bump actions/download-artifact from 4 to 8
(#1041)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)
---
updated-dependencies:
- dependency-name: actions/download-artifact
dependency-version: '8'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jehonathan Thomas
---
.github/workflows/release.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 59f9eeefc..2d2c87103 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -526,7 +526,7 @@ jobs:
- rid: linux-musl-x64
container: alpine:3.24
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v8
with:
name: cli-${{ matrix.rid }}
path: dist
@@ -624,7 +624,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v8
with:
path: artifacts
- name: Attach MIT NuGet SBOM when present
From b40ad85b18afb527287c6b8c05973e977a894090 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 16:42:15 -0700
Subject: [PATCH 05/63] chore(deps): bump actions/cache from 4 to 6 (#1042)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)
---
updated-dependencies:
- dependency-name: actions/cache
dependency-version: '6'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jehonathan Thomas
---
.github/workflows/release.yml | 4 ++--
.github/workflows/rps-saturation.yml | 6 +++---
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2d2c87103..297dd648a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -187,7 +187,7 @@ jobs:
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- - uses: actions/cache@v4
+ - uses: actions/cache@v6
with:
path: tools/packaging/.cache/http3-natives
key: http3-natives-${{ hashFiles('tools/packaging/http3-native.lock.json') }}-${{ matrix.rid }}
@@ -367,7 +367,7 @@ jobs:
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- - uses: actions/cache@v4
+ - uses: actions/cache@v6
with:
path: tools/packaging/.cache/http3-natives
key: http3-natives-${{ hashFiles('tools/packaging/http3-native.lock.json') }}-${{ matrix.rid }}
diff --git a/.github/workflows/rps-saturation.yml b/.github/workflows/rps-saturation.yml
index a3d2e37c5..a2d3ea610 100644
--- a/.github/workflows/rps-saturation.yml
+++ b/.github/workflows/rps-saturation.yml
@@ -1,4 +1,4 @@
-# Manual saturation RPS lab on GitHub-hosted VMs (not a job container).
+# Manual saturation RPS lab on GitHub-hosted VMs (not a job container).
# Maintainers run this, then paste median numbers into wiki/Performance.md.
# Not a per-PR gate; not comparable to dedicated-server blog posts.
#
@@ -380,7 +380,7 @@ jobs:
# macOS uses Homebrew (typically native USE_QUIC) with a 3.2 osx source fallback.
- name: Cache HAProxy QUIC prefix
if: runner.os == 'Linux'
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: |
${{ runner.temp }}/haproxy-quic
@@ -470,7 +470,7 @@ jobs:
- name: Cache HAProxy QUIC prefix (macOS)
if: runner.os == 'macOS'
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ${{ runner.temp }}/haproxy-quic
key: haproxy-3.2.23-quic-osx-x64
From ab6fe3443f54513d07e1e1df80efe535564432cf Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Tue, 15 Sep 2026 17:39:26 -0700
Subject: [PATCH 06/63] fix(inspector): accept * and localhost binds; recover
after invalid address.
A bad bind left Start stuck because parse ran outside the start-busy finally. Treat * as all IPv4 adapters to match the CLI.
---
.../ViewModels/MainWindowViewModel.Trust.cs | 2 +-
.../ViewModels/MainWindowViewModel.cs | 44 +++++++----
src/Titanium.Inspector/Views/MainWindow.axaml | 1 +
.../BindEndpointUxTests.cs | 78 +++++++++++++++++++
.../InspectorCommandCoverageTests.cs | 9 +++
website/docs/configuration.md | 2 +-
website/docs/inspector.md | 13 ++++
7 files changed, 132 insertions(+), 17 deletions(-)
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs
index 2984ef1b4..0913ef724 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs
@@ -859,7 +859,7 @@ private async Task DeviceCaSetupAsync()
"2. Install the exported .cer (or .pem) on the device as a trusted CA.\n" +
$"3. Set the device HTTP proxy to this PC's LAN IP on port {BindPort} " +
$"(current bind is {BindAddress}:{BindPort}).\n\n" +
- "Use Bind address 0.0.0.0 so other devices can reach the proxy.";
+ "Use Bind address 0.0.0.0 or * so other devices can reach the proxy.";
var owner = TryGetMainWindow();
if (await AwaitDialogAsync(_dialogs.ShowDeviceCaSetupAsync(owner, message)))
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
index 252f710cf..fe15ac8f3 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
@@ -2893,20 +2893,30 @@ private async Task StartCaptureAsync()
using var scope = InspectorUxTrace.Scope("StartCapture", $"{BindAddress}:{BindPort}");
InspectorUxTrace.Event("UxTrace.Path", InspectorUxTrace.LogFilePath);
_startBusy = true;
- var address = ParseBindAddress(BindAddress);
- PersistSettings();
- _interception.BreakpointOnResponse = BreakpointOnResponse;
- _interception.ScriptOnRequest = ScriptOnRequest;
- _interception.ScriptOnResponse = ScriptOnResponse;
- _interception.IgnoreServerCertificateErrors = _settings.Current.IgnoreServerCertificateErrors;
- _interception.AddViaHeader = _settings.Current.AddViaHeader;
- _interception.DecryptHttps = _decryptHttps;
- _interception.ConfigureLogging(_settings.Current);
- SetStatus("Starting proxy…", StatusSeverity.Busy);
- var token = StatusCancelToken;
- var port = BindPort;
try
{
+ IPAddress address;
+ try
+ {
+ address = ParseBindAddress(BindAddress);
+ }
+ catch (Exception ex) when (ex is FormatException or ArgumentException)
+ {
+ SetOutcomeStatus(InvalidBindAddressMessage(BindAddress), StatusSeverity.Error, toastImportant: true);
+ return;
+ }
+
+ PersistSettings();
+ _interception.BreakpointOnResponse = BreakpointOnResponse;
+ _interception.ScriptOnRequest = ScriptOnRequest;
+ _interception.ScriptOnResponse = ScriptOnResponse;
+ _interception.IgnoreServerCertificateErrors = _settings.Current.IgnoreServerCertificateErrors;
+ _interception.AddViaHeader = _settings.Current.AddViaHeader;
+ _interception.DecryptHttps = _decryptHttps;
+ _interception.ConfigureLogging(_settings.Current);
+ SetStatus("Starting proxy…", StatusSeverity.Busy);
+ var token = StatusCancelToken;
+ var port = BindPort;
// Listener start + first Root-store trust refresh can stall Crypt32 — keep off UI.
// Use async Task.Run (not GetResult) to avoid sync-over-async deadlocks on a sync context.
await Task.Run(
@@ -2973,19 +2983,23 @@ private void RefreshEndpointAndBindUi()
private static IPAddress ParseBindAddress(string bindAddress)
{
- if (string.IsNullOrWhiteSpace(bindAddress) || bindAddress == "0.0.0.0")
+ var host = (bindAddress ?? string.Empty).Trim();
+ if (host.Length == 0 || host is "0.0.0.0" or "*")
{
return IPAddress.Any;
}
- if (bindAddress == "127.0.0.1")
+ if (host == "127.0.0.1" || host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return IPAddress.Loopback;
}
- return IPAddress.Parse(bindAddress);
+ return IPAddress.Parse(host);
}
+ internal static string InvalidBindAddressMessage(string bindAddress) =>
+ $"Invalid bind address '{bindAddress}'. Use 127.0.0.1 or localhost for this PC only, or 0.0.0.0 or * for all network adapters.";
+
diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml
index 72f1fbc9d..eb3cb3503 100644
--- a/src/Titanium.Inspector/Views/MainWindow.axaml
+++ b/src/Titanium.Inspector/Views/MainWindow.axaml
@@ -187,6 +187,7 @@
vm.StatusText.Contains("Invalid bind address", StringComparison.Ordinal));
+ Assert.IsFalse(interception.IsRunning);
+ StringAssert.Contains(vm.StatusText, MainWindowViewModel.InvalidBindAddressMessage("::::"));
+
+ vm.BindAddress = "*";
+ vm.StartCaptureCommand.Execute(null);
+ await WaitUntil(() => interception.IsRunning &&
+ vm.EndpointStatusText.StartsWith("Proxy running", StringComparison.Ordinal));
+ Assert.AreEqual($"Proxy running on 0.0.0.0:{vm.BindPort}", vm.EndpointStatusText);
+
+ vm.EnsureShutdown();
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
+ [TestMethod]
+ public async Task LocalhostAlias_StartsLoopbackListener()
+ {
+ var path = TempSettingsPath();
+ try
+ {
+ var settings = new SettingsService(path);
+ settings.Current.AutoStartCapture = false;
+ settings.Current.AutoSystemProxyOnStart = false;
+ settings.Save();
+
+ var recorder = new RecordingSystemProxyController();
+ using var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true };
+ var registry = new SessionRegistry();
+ var vm = new MainWindowViewModel(
+ new SessionStreamBuffer(registry),
+ registry,
+ new UpdateService(settings),
+ settings,
+ interception);
+
+ vm.BindPort = 0;
+ vm.BindAddress = "localhost";
+ vm.StartCaptureCommand.Execute(null);
+ await WaitUntil(() => interception.IsRunning &&
+ vm.EndpointStatusText.StartsWith("Proxy running", StringComparison.Ordinal));
+ Assert.AreEqual($"Proxy running on localhost:{vm.BindPort}", vm.EndpointStatusText);
+
+ vm.EnsureShutdown();
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
[TestMethod]
public async Task ManualStart_WithAutoSystemProxyOnStart_EnablesSystemProxy()
{
diff --git a/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs b/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs
index 95c2d0092..20571c68f 100644
--- a/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs
+++ b/tests/Titanium.Inspector.Tests/InspectorCommandCoverageTests.cs
@@ -237,10 +237,16 @@ public async Task RemainingCommands_CopyDiffComposerAutoResponderMapRemoteAndTog
var flagsVm = BindingFlags.NonPublic | BindingFlags.Static;
Assert.AreEqual(IPAddress.Any,
(IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["0.0.0.0"])!);
+ Assert.AreEqual(IPAddress.Any,
+ (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["*"])!);
Assert.AreEqual(IPAddress.Any,
(IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, [" "])!);
Assert.AreEqual(IPAddress.Loopback,
(IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["127.0.0.1"])!);
+ Assert.AreEqual(IPAddress.Loopback,
+ (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["localhost"])!);
+ Assert.AreEqual(IPAddress.Loopback,
+ (IPAddress)typeof(MainWindowViewModel).GetMethod("ParseBindAddress", flagsVm)!.Invoke(null, ["LocalHost"])!);
Assert.AreEqual("h.test",
(string?)typeof(MainWindowViewModel).GetMethod("TryHost", flagsVm)!.Invoke(null, ["https://h.test/x"]));
Assert.IsNull(typeof(MainWindowViewModel).GetMethod("TryHost", flagsVm)!.Invoke(null, ["not-a-url"]));
@@ -514,6 +520,9 @@ public void SelectedInspectFormatters_AndBindDebugHelpers_CoverBranches()
BindPort = 8888,
BindAddress = "0.0.0.0",
};
+ Assert.AreEqual("0.0.0.0",
+ (string)vmType.GetMethod("FormatBindDisplay", flags)!.Invoke(vm, null)!);
+ vm.BindAddress = "*";
Assert.AreEqual("0.0.0.0",
(string)vmType.GetMethod("FormatBindDisplay", flags)!.Invoke(vm, null)!);
vm.BindAddress = "127.0.0.1";
diff --git a/website/docs/configuration.md b/website/docs/configuration.md
index 019a5a0de..d93245c8c 100644
--- a/website/docs/configuration.md
+++ b/website/docs/configuration.md
@@ -43,7 +43,7 @@ Engine knobs live under `server:` ([reference](#server-reference)). Plus feature
| Field | Type | Notes |
|-------|------|-------|
-| `host` | string | Default `0.0.0.0` |
+| `host` | string | Bind address. Default `0.0.0.0` (all IPv4 interfaces). `*` is the same as `0.0.0.0`. Use `127.0.0.1` for localhost only. |
| `port` | int | Default `8000` |
| `decryptSsl` | bool | Terminate TLS / decrypt HTTPS (man-in-the-middle when used for MITM) |
| `type` | string? | `explicit`, `transparent`, `socks`, or `quic` (null uses ForwardHost heuristics) |
diff --git a/website/docs/inspector.md b/website/docs/inspector.md
index 3740a77bc..2aa79ba29 100644
--- a/website/docs/inspector.md
+++ b/website/docs/inspector.md
@@ -13,6 +13,19 @@ Desktop debugger for HTTP and HTTPS traffic. Decrypt HTTPS (man-in-the-middle /
HTTPS stays encrypted (opaque tunnels) until **Decrypt HTTPS** is on.
+### Bind address
+
+Toolbar **Bind address** is an IP (or alias), not a hostname except `localhost`. Edit it only while the proxy is stopped.
+
+| Value | Listens on |
+| --- | --- |
+| `127.0.0.1` or `localhost` | This PC only |
+| `0.0.0.0` or `*` | All IPv4 adapters, including localhost |
+| A NIC IP (for example `192.168.1.10`) | That interface only |
+| `::` | All IPv6 adapters |
+
+`*` matches the CLI listener `host` alias for all IPv4 interfaces. Binding on all adapters means other machines on the network can reach the proxy — use that only on a network you trust. For a phone or other device, bind `0.0.0.0` or `*` and point the device at this PC's LAN IP (see **Capture → device CA setup**).
+
Capture menu options (**Capturing**, **Decrypt HTTPS**, **System proxy**, **Capture local traffic**, auto-start prefs) show a check when on. **Allow Store apps…** (Windows) sits with System proxy. Preferences such as **Session retention…**, **Excluded hosts…**, **Ignore insecure server certificates**, and **Logging…** live under **Options**.
## Install
From ff2c344bdc79bcbbf5ba93643f1108815cea890b Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Tue, 15 Sep 2026 18:19:38 -0700
Subject: [PATCH 07/63] fix(inspector): treat port and AutoResponder status as
text, not int binds.
Empty fields no longer raise conversion exceptions; * picks an OS port, and invalid input shows a short message.
---
.../MainWindowViewModel.Sessions.cs | 27 ++-
.../ViewModels/MainWindowViewModel.cs | 207 +++++++++++++++++-
src/Titanium.Inspector/Views/MainWindow.axaml | 8 +-
.../Views/SessionRetentionWindow.axaml.cs | 37 +++-
.../AutoResponderAndSelectionGuardTests.cs | 69 ++++++
.../BindEndpointUxTests.cs | 110 ++++++++++
.../CaptureSettingsParityTests.cs | 6 +
website/docs/configuration.md | 2 +-
website/docs/inspector.md | 14 +-
9 files changed, 461 insertions(+), 19 deletions(-)
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs
index 86076e892..bb90056aa 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs
@@ -434,6 +434,11 @@ private List ResolveCopyUrls() =>
.ToList();
private Task AddAutoResponderRuleAsync()
{
+ if (!TryGetAutoResponderStatus(out var status))
+ {
+ return Task.CompletedTask;
+ }
+
if (string.IsNullOrWhiteSpace(AutoResponderLocalFilePath)
&& AutoResponderBody.Length > InspectorBodyLimits.MaxInlineToolBodyChars)
{
@@ -444,7 +449,7 @@ private Task AddAutoResponderRuleAsync()
AutoResponder.Rules.Add(new AutoResponderRule
{
MatchUrl = AutoResponderMatch,
- StatusCode = AutoResponderStatus,
+ StatusCode = status,
Body = AutoResponderBody,
ContentType = AutoResponderContentType,
LocalFilePath = AutoResponderLocalFilePath,
@@ -477,6 +482,11 @@ private Task UpdateAutoResponderRuleAsync()
return Task.CompletedTask;
}
+ if (!TryGetAutoResponderStatus(out var status))
+ {
+ return Task.CompletedTask;
+ }
+
if (string.IsNullOrWhiteSpace(AutoResponderLocalFilePath)
&& AutoResponderBody.Length > InspectorBodyLimits.MaxInlineToolBodyChars)
{
@@ -486,7 +496,7 @@ private Task UpdateAutoResponderRuleAsync()
var rule = AutoResponder.SelectedRule;
rule.MatchUrl = AutoResponderMatch;
- rule.StatusCode = AutoResponderStatus;
+ rule.StatusCode = status;
rule.Body = AutoResponderBody;
rule.ContentType = AutoResponderContentType;
rule.LocalFilePath = AutoResponderLocalFilePath;
@@ -495,6 +505,19 @@ private Task UpdateAutoResponderRuleAsync()
StatusText = "AutoResponder rule updated";
return Task.CompletedTask;
}
+
+ private bool TryGetAutoResponderStatus(out int status)
+ {
+ if (TryParseHttpStatus(AutoResponderStatusText, out status))
+ {
+ AutoResponderStatus = status;
+ return true;
+ }
+
+ SetOutcomeStatus(InvalidHttpStatusMessage(AutoResponderStatusText), StatusSeverity.Error, toastImportant: true);
+ status = 0;
+ return false;
+ }
private async Task BrowseAutoResponderLocalFileAsync()
{
var path = await _pathPicker.PickOpenPathAsync(
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
index fe15ac8f3..5edf14dc2 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
@@ -1,5 +1,7 @@
+using System.Collections;
using System.Collections.ObjectModel;
using System.ComponentModel;
+using System.Globalization;
using System.Net;
using System.Runtime.CompilerServices;
using System.Text;
@@ -16,7 +18,7 @@
namespace Titanium.Inspector.ViewModels;
-public sealed partial class MainWindowViewModel : INotifyPropertyChanged
+public sealed partial class MainWindowViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
private const string ZipFileFilter = "*.zip";
@@ -89,6 +91,7 @@ public sealed partial class MainWindowViewModel : INotifyPropertyChanged
private string _autoResponderContentType = "text/plain";
private string _autoResponderLocalFilePath = string.Empty;
private int _autoResponderStatus = 200;
+ private string _autoResponderStatusText = "200";
private string _mapRemoteMatch = "*";
private string _mapRemoteTarget = "http://127.0.0.1/";
private string _mapRemoteGraphQlOperation = string.Empty;
@@ -96,6 +99,7 @@ public sealed partial class MainWindowViewModel : INotifyPropertyChanged
private string _plusPanelsSummary = "";
private string _bindAddress = "127.0.0.1";
private int _bindPort = 8866;
+ private string _bindPortText = "8866";
private string _endpointStatusText = "Proxy stopped";
private string _interceptToggleText = "Start proxy";
/// Sticky intent: re-enable system proxy on the next Start after a Stop that had it on.
@@ -1359,9 +1363,95 @@ public string BindAddress
public int BindPort
{
get => _bindPort;
- set => SetField(ref _bindPort, value);
+ set
+ {
+ SetField(ref _bindPort, value);
+ if (!TryParseBindPort(_bindPortText, out var represented) || represented != value)
+ {
+ _bindPortText = FormatBindPortText(value);
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPortText)));
+ NotifyBindPortErrors();
+ }
+ }
}
+ /// Toolbar port box. Empty or * is an OS-chosen port; keep this a string so typing is not an int conversion error.
+ public string BindPortText
+ {
+ get => _bindPortText;
+ set
+ {
+ var text = value ?? string.Empty;
+ if (!SetField(ref _bindPortText, text))
+ {
+ return;
+ }
+
+ if (TryParseBindPort(text, out var port))
+ {
+ _bindPort = port;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPort)));
+ }
+
+ NotifyBindPortErrors();
+ }
+ }
+
+ public bool HasErrors =>
+ !TryParseBindPort(_bindPortText, out _) ||
+ !TryParseHttpStatus(_autoResponderStatusText, out _);
+
+ public event EventHandler? ErrorsChanged;
+
+ public IEnumerable GetErrors(string? propertyName)
+ {
+ if (string.IsNullOrEmpty(propertyName))
+ {
+ return ConcatErrors(BindPortErrors(), AutoResponderStatusErrors());
+ }
+
+ if (propertyName == nameof(BindPortText))
+ {
+ return BindPortErrors();
+ }
+
+ if (propertyName == nameof(AutoResponderStatusText))
+ {
+ return AutoResponderStatusErrors();
+ }
+
+ return Array.Empty();
+ }
+
+ private IEnumerable BindPortErrors() =>
+ TryParseBindPort(_bindPortText, out _)
+ ? Array.Empty()
+ : new object[] { InvalidBindPortMessage(_bindPortText) };
+
+ private IEnumerable AutoResponderStatusErrors() =>
+ TryParseHttpStatus(_autoResponderStatusText, out _)
+ ? Array.Empty()
+ : new object[] { InvalidHttpStatusMessage(_autoResponderStatusText) };
+
+ private static IEnumerable ConcatErrors(IEnumerable first, IEnumerable second)
+ {
+ foreach (var item in first)
+ {
+ yield return item;
+ }
+
+ foreach (var item in second)
+ {
+ yield return item;
+ }
+ }
+
+ private void NotifyBindPortErrors() =>
+ ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(BindPortText)));
+
+ private void NotifyAutoResponderStatusErrors() =>
+ ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(AutoResponderStatusText)));
+
/// Bind address/port are start-time config; editable only while the proxy is stopped.
public bool BindFieldsEnabled => !_interception.IsRunning;
@@ -1513,7 +1603,38 @@ public string AutoResponderGraphQlOperation
public int AutoResponderStatus
{
get => _autoResponderStatus;
- set => SetField(ref _autoResponderStatus, value);
+ set
+ {
+ SetField(ref _autoResponderStatus, value);
+ if (!TryParseHttpStatus(_autoResponderStatusText, out var represented) || represented != value)
+ {
+ _autoResponderStatusText = FormatHttpStatusText(value);
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoResponderStatusText)));
+ NotifyAutoResponderStatusErrors();
+ }
+ }
+ }
+
+ /// AutoResponder status box. Empty means 200; keep this a string so typing is not an int conversion error.
+ public string AutoResponderStatusText
+ {
+ get => _autoResponderStatusText;
+ set
+ {
+ var text = value ?? string.Empty;
+ if (!SetField(ref _autoResponderStatusText, text))
+ {
+ return;
+ }
+
+ if (TryParseHttpStatus(text, out var status))
+ {
+ _autoResponderStatus = status;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoResponderStatus)));
+ }
+
+ NotifyAutoResponderStatusErrors();
+ }
}
public string PlusPanelsSummary
@@ -2396,7 +2517,7 @@ private void LoadFromSettings()
{
var s = _settings.Current;
BindAddress = s.BindAddress;
- BindPort = s.BindPort is > 0 and < 65536 ? s.BindPort : 8866;
+ BindPort = s.BindPort is >= 0 and <= 65535 ? s.BindPort : 8866;
_launchAutoStartCapture = _autoStartCapture = s.AutoStartCapture;
_launchAutoSystemProxyOnStart = _autoSystemProxyOnStart = s.AutoSystemProxyOnStart;
_decryptHttps = s.DecryptHttps;
@@ -2432,6 +2553,7 @@ private void NotifySettingsUiChanged()
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindAddress)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPort)));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BindPortText)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoStartCapture)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AutoSystemProxyOnStart)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps)));
@@ -2890,7 +3012,7 @@ private async Task StartCaptureAsync()
return;
}
- using var scope = InspectorUxTrace.Scope("StartCapture", $"{BindAddress}:{BindPort}");
+ using var scope = InspectorUxTrace.Scope("StartCapture", $"{BindAddress}:{BindPortText}");
InspectorUxTrace.Event("UxTrace.Path", InspectorUxTrace.LogFilePath);
_startBusy = true;
try
@@ -2906,6 +3028,18 @@ private async Task StartCaptureAsync()
return;
}
+ int port;
+ try
+ {
+ port = ParseBindPort(BindPortText);
+ }
+ catch (FormatException)
+ {
+ SetOutcomeStatus(InvalidBindPortMessage(BindPortText), StatusSeverity.Error, toastImportant: true);
+ return;
+ }
+
+ BindPort = port;
PersistSettings();
_interception.BreakpointOnResponse = BreakpointOnResponse;
_interception.ScriptOnRequest = ScriptOnRequest;
@@ -2916,7 +3050,6 @@ private async Task StartCaptureAsync()
_interception.ConfigureLogging(_settings.Current);
SetStatus("Starting proxy…", StatusSeverity.Busy);
var token = StatusCancelToken;
- var port = BindPort;
// Listener start + first Root-store trust refresh can stall Crypt32 — keep off UI.
// Use async Task.Run (not GetResult) to avoid sync-over-async deadlocks on a sync context.
await Task.Run(
@@ -3000,6 +3133,68 @@ private static IPAddress ParseBindAddress(string bindAddress)
internal static string InvalidBindAddressMessage(string bindAddress) =>
$"Invalid bind address '{bindAddress}'. Use 127.0.0.1 or localhost for this PC only, or 0.0.0.0 or * for all network adapters.";
+ internal static bool TryParseBindPort(string? text, out int port)
+ {
+ var raw = (text ?? string.Empty).Trim();
+ if (raw.Length == 0 || raw == "*")
+ {
+ port = 0;
+ return true;
+ }
+
+ if (int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)
+ && parsed is >= 0 and <= 65535)
+ {
+ port = parsed;
+ return true;
+ }
+
+ port = 0;
+ return false;
+ }
+
+ internal static int ParseBindPort(string text)
+ {
+ if (TryParseBindPort(text, out var port))
+ {
+ return port;
+ }
+
+ throw new FormatException(InvalidBindPortMessage(text));
+ }
+
+ internal static string FormatBindPortText(int port) =>
+ port == 0 ? "*" : port.ToString(CultureInfo.InvariantCulture);
+
+ internal static string InvalidBindPortMessage(string text) =>
+ $"Invalid port '{text}'. Use 1–65535, or * (or empty) for an OS-chosen port.";
+
+ internal static bool TryParseHttpStatus(string? text, out int status)
+ {
+ var raw = (text ?? string.Empty).Trim();
+ if (raw.Length == 0)
+ {
+ status = 200;
+ return true;
+ }
+
+ if (int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)
+ && parsed is >= 100 and <= 599)
+ {
+ status = parsed;
+ return true;
+ }
+
+ status = 0;
+ return false;
+ }
+
+ internal static string FormatHttpStatusText(int status) =>
+ status.ToString(CultureInfo.InvariantCulture);
+
+ internal static string InvalidHttpStatusMessage(string text) =>
+ $"Invalid status '{text}'. Use an HTTP status 100–599, or leave empty for 200.";
+
diff --git a/src/Titanium.Inspector/Views/MainWindow.axaml b/src/Titanium.Inspector/Views/MainWindow.axaml
index eb3cb3503..a198193fe 100644
--- a/src/Titanium.Inspector/Views/MainWindow.axaml
+++ b/src/Titanium.Inspector/Views/MainWindow.axaml
@@ -184,14 +184,15 @@
-
-
-
diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
index 35f8776f7..59e7f9052 100644
--- a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
+++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml.cs
@@ -65,13 +65,33 @@ private void OnOpenCacheFolder(object? sender, RoutedEventArgs e)
private void OnSave(object? sender, RoutedEventArgs e)
{
- if (!TryParsePositiveInt(MaxSessionsBox.Text, out var maxSessions) ||
- !TryParsePositiveInt(HotBodySessionsBox.Text, out var hotBodies) ||
- !TryParsePositiveInt(DiskCacheMaxAgeDaysBox.Text, out var maxAgeDays) ||
- !TryParsePositiveLong(DiskCacheMaxMbBox.Text, out var diskMb) ||
- !TryParsePositiveLong(MaxBodyRamMbBox.Text, out var ramMb))
+ if (!TryParsePositiveInt(MaxSessionsBox.Text, out var maxSessions))
{
- StatusText.Text = "Enter positive numbers for all fields.";
+ StatusText.Text = FormatPositiveNumberError("Maximum sessions", MaxSessionsBox.Text);
+ return;
+ }
+
+ if (!TryParsePositiveInt(HotBodySessionsBox.Text, out var hotBodies))
+ {
+ StatusText.Text = FormatPositiveNumberError("Keep full bodies in memory", HotBodySessionsBox.Text);
+ return;
+ }
+
+ if (!TryParsePositiveInt(DiskCacheMaxAgeDaysBox.Text, out var maxAgeDays))
+ {
+ StatusText.Text = FormatPositiveNumberError("Delete cached bodies older than (days)", DiskCacheMaxAgeDaysBox.Text);
+ return;
+ }
+
+ if (!TryParsePositiveLong(DiskCacheMaxMbBox.Text, out var diskMb))
+ {
+ StatusText.Text = FormatPositiveNumberError("Disk cache size limit (MB)", DiskCacheMaxMbBox.Text);
+ return;
+ }
+
+ if (!TryParsePositiveLong(MaxBodyRamMbBox.Text, out var ramMb))
+ {
+ StatusText.Text = FormatPositiveNumberError("Memory for request/response bodies (MB)", MaxBodyRamMbBox.Text);
return;
}
@@ -102,4 +122,9 @@ public static bool TryParsePositiveLong(string? text, out long value)
value = 0;
return long.TryParse(text?.Trim(), out value) && value > 0;
}
+
+ public static string FormatPositiveNumberError(string field, string? text) =>
+ string.IsNullOrWhiteSpace(text)
+ ? $"{field}: enter a whole number greater than 0."
+ : $"{field}: '{text.Trim()}' is not a whole number greater than 0.";
}
diff --git a/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs b/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs
index e16acf1a6..7eebf1b49 100644
--- a/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs
+++ b/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs
@@ -1,3 +1,5 @@
+using System.ComponentModel;
+using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Titanium.Inspector.Services;
using Titanium.Inspector.ViewModels;
@@ -245,6 +247,73 @@ public async Task CopyUrl_WithSelection_SetsCopiedStatus()
}
}
+ [TestMethod]
+ public void HttpStatus_ParsesEmptyAs200_RejectsJunk()
+ {
+ Assert.IsTrue(MainWindowViewModel.TryParseHttpStatus("", out var empty));
+ Assert.AreEqual(200, empty);
+ Assert.IsTrue(MainWindowViewModel.TryParseHttpStatus("404", out var notFound));
+ Assert.AreEqual(404, notFound);
+ Assert.IsFalse(MainWindowViewModel.TryParseHttpStatus("abc", out _));
+ Assert.IsFalse(MainWindowViewModel.TryParseHttpStatus("99", out _));
+ Assert.IsFalse(MainWindowViewModel.TryParseHttpStatus("600", out _));
+ }
+
+ [TestMethod]
+ public async Task AutoResponderStatusText_EmptyHasNoError_InvalidCharsAreFriendly()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "twp-ar-status-" + Guid.NewGuid().ToString("N") + ".json");
+ try
+ {
+ var settings = new SettingsService(path);
+ settings.Current.AutoStartCapture = false;
+ settings.Current.AutoSystemProxyOnStart = false;
+ settings.Save();
+ using var interception = new InterceptionService(new RecordingSystemProxyController())
+ {
+ UseInMemoryTrustState = true,
+ };
+ var registry = new SessionRegistry();
+ var vm = new MainWindowViewModel(
+ new SessionStreamBuffer(registry),
+ registry,
+ new UpdateService(settings),
+ settings,
+ interception);
+ var errors = (INotifyDataErrorInfo)vm;
+
+ vm.AutoResponderStatusText = "";
+ Assert.IsFalse(((INotifyDataErrorInfo)vm).GetErrors(nameof(MainWindowViewModel.AutoResponderStatusText)).Cast().Any());
+ Assert.AreEqual(200, vm.AutoResponderStatus);
+
+ vm.AutoResponderStatusText = "abc";
+ var messages = errors.GetErrors(nameof(MainWindowViewModel.AutoResponderStatusText)).Cast().ToList();
+ Assert.AreEqual(1, messages.Count);
+ Assert.AreEqual(MainWindowViewModel.InvalidHttpStatusMessage("abc"), messages[0]);
+
+ var before = vm.AutoResponder.Rules.Count;
+ vm.AddAutoResponderRuleCommand.Execute(null);
+ await Task.Delay(50);
+ Assert.AreEqual(before, vm.AutoResponder.Rules.Count);
+ StringAssert.Contains(vm.StatusText, "Invalid status");
+
+ vm.AutoResponderStatusText = "";
+ vm.AddAutoResponderRuleCommand.Execute(null);
+ await Task.Delay(50);
+ Assert.AreEqual(before + 1, vm.AutoResponder.Rules.Count);
+ Assert.AreEqual(200, vm.AutoResponder.Rules[^1].StatusCode);
+
+ vm.EnsureShutdown();
+ }
+ finally
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+
[TestMethod]
public void InterceptionService_ApplyHttpProtocolsAndConfigureLogging_WhenNotStarted()
{
diff --git a/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs b/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs
index 51421232b..dc9222717 100644
--- a/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs
+++ b/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs
@@ -1,3 +1,5 @@
+using System.ComponentModel;
+using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Titanium.Inspector.Services;
using Titanium.Inspector.ViewModels;
@@ -233,6 +235,114 @@ await WaitUntil(() => interception.IsRunning &&
}
}
+ [TestMethod]
+ public void BindPortText_ParsesStarEmptyAndRejectsJunk()
+ {
+ Assert.IsTrue(MainWindowViewModel.TryParseBindPort("", out var empty));
+ Assert.AreEqual(0, empty);
+ Assert.IsTrue(MainWindowViewModel.TryParseBindPort(" * ", out var star));
+ Assert.AreEqual(0, star);
+ Assert.IsTrue(MainWindowViewModel.TryParseBindPort("0", out var zero));
+ Assert.AreEqual(0, zero);
+ Assert.IsTrue(MainWindowViewModel.TryParseBindPort("65535", out var max));
+ Assert.AreEqual(65535, max);
+ Assert.IsFalse(MainWindowViewModel.TryParseBindPort("abc", out _));
+ Assert.IsFalse(MainWindowViewModel.TryParseBindPort("99999", out _));
+ Assert.AreEqual("*", MainWindowViewModel.FormatBindPortText(0));
+ Assert.AreEqual("8866", MainWindowViewModel.FormatBindPortText(8866));
+ }
+
+ [TestMethod]
+ public void BindPortText_EmptyHasNoError_InvalidCharsAreFriendly()
+ {
+ var path = TempSettingsPath();
+ try
+ {
+ var settings = new SettingsService(path);
+ settings.Current.AutoStartCapture = false;
+ settings.Current.AutoSystemProxyOnStart = false;
+ settings.Save();
+
+ using var interception = new InterceptionService(new RecordingSystemProxyController())
+ {
+ UseInMemoryTrustState = true,
+ };
+ var registry = new SessionRegistry();
+ var vm = new MainWindowViewModel(
+ new SessionStreamBuffer(registry),
+ registry,
+ new UpdateService(settings),
+ settings,
+ interception);
+ var errors = (INotifyDataErrorInfo)vm;
+
+ vm.BindPortText = "";
+ Assert.IsFalse(vm.HasErrors);
+ Assert.AreEqual(0, vm.BindPort);
+ Assert.AreEqual(0, errors.GetErrors(nameof(MainWindowViewModel.BindPortText)).Cast().Count());
+
+ vm.BindPortText = "abc";
+ Assert.IsTrue(vm.HasErrors);
+ var messages = errors.GetErrors(nameof(MainWindowViewModel.BindPortText)).Cast().ToList();
+ Assert.AreEqual(1, messages.Count);
+ Assert.AreEqual(MainWindowViewModel.InvalidBindPortMessage("abc"), messages[0]);
+
+ vm.BindPort = 0;
+ Assert.AreEqual("*", vm.BindPortText);
+ Assert.IsFalse(vm.HasErrors);
+
+ vm.EnsureShutdown();
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
+ [TestMethod]
+ public async Task InvalidBindPort_ClearsStartBusy_StarStartsEphemeral()
+ {
+ var path = TempSettingsPath();
+ try
+ {
+ var settings = new SettingsService(path);
+ settings.Current.AutoStartCapture = false;
+ settings.Current.AutoSystemProxyOnStart = false;
+ settings.Save();
+
+ var recorder = new RecordingSystemProxyController();
+ using var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true };
+ var registry = new SessionRegistry();
+ var vm = new MainWindowViewModel(
+ new SessionStreamBuffer(registry),
+ registry,
+ new UpdateService(settings),
+ settings,
+ interception);
+
+ vm.BindAddress = "127.0.0.1";
+ vm.BindPortText = "nope";
+ vm.StartCaptureCommand.Execute(null);
+ await WaitUntil(() => vm.StatusText.Contains("Invalid port", StringComparison.Ordinal));
+ Assert.IsFalse(interception.IsRunning);
+ StringAssert.Contains(vm.StatusText, MainWindowViewModel.InvalidBindPortMessage("nope"));
+
+ vm.BindPortText = "*";
+ vm.StartCaptureCommand.Execute(null);
+ await WaitUntil(() => interception.IsRunning &&
+ vm.EndpointStatusText.StartsWith("Proxy running", StringComparison.Ordinal) &&
+ vm.BindPort > 0);
+ Assert.AreEqual($"Proxy running on 127.0.0.1:{vm.BindPort}", vm.EndpointStatusText);
+ Assert.AreEqual(vm.BindPort.ToString(), vm.BindPortText);
+
+ vm.EnsureShutdown();
+ }
+ finally
+ {
+ TryDelete(path);
+ }
+ }
+
[TestMethod]
public async Task ManualStart_WithAutoSystemProxyOnStart_EnablesSystemProxy()
{
diff --git a/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs b/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs
index ae7abbdc0..055a7e2c8 100644
--- a/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs
+++ b/tests/Titanium.Inspector.Tests/CaptureSettingsParityTests.cs
@@ -21,6 +21,12 @@ public void SessionRetention_MbConversion_RoundTrips()
Assert.AreEqual(10000, n);
Assert.IsFalse(SessionRetentionWindow.TryParsePositiveInt("0", out _));
Assert.IsFalse(SessionRetentionWindow.TryParsePositiveLong("-1", out _));
+ Assert.AreEqual(
+ "Maximum sessions: enter a whole number greater than 0.",
+ SessionRetentionWindow.FormatPositiveNumberError("Maximum sessions", " "));
+ Assert.AreEqual(
+ "Maximum sessions: 'abc' is not a whole number greater than 0.",
+ SessionRetentionWindow.FormatPositiveNumberError("Maximum sessions", "abc"));
}
[TestMethod]
diff --git a/website/docs/configuration.md b/website/docs/configuration.md
index d93245c8c..459bf3a83 100644
--- a/website/docs/configuration.md
+++ b/website/docs/configuration.md
@@ -44,7 +44,7 @@ Engine knobs live under `server:` ([reference](#server-reference)). Plus feature
| Field | Type | Notes |
|-------|------|-------|
| `host` | string | Bind address. Default `0.0.0.0` (all IPv4 interfaces). `*` is the same as `0.0.0.0`. Use `127.0.0.1` for localhost only. |
-| `port` | int | Default `8000` |
+| `port` | int | Default `8000`. `0` is an OS-chosen (ephemeral) port. Inspector toolbar `*` / empty means the same. |
| `decryptSsl` | bool | Terminate TLS / decrypt HTTPS (man-in-the-middle when used for MITM) |
| `type` | string? | `explicit`, `transparent`, `socks`, or `quic` (null uses ForwardHost heuristics) |
| `forwardHost` / `forwardPort` | string / int | Classic single-origin reverse (no route table) |
diff --git a/website/docs/inspector.md b/website/docs/inspector.md
index 2aa79ba29..3640b573e 100644
--- a/website/docs/inspector.md
+++ b/website/docs/inspector.md
@@ -26,6 +26,18 @@ Toolbar **Bind address** is an IP (or alias), not a hostname except `localhost`.
`*` matches the CLI listener `host` alias for all IPv4 interfaces. Binding on all adapters means other machines on the network can reach the proxy — use that only on a network you trust. For a phone or other device, bind `0.0.0.0` or `*` and point the device at this PC's LAN IP (see **Capture → device CA setup**).
+### Bind port
+
+Toolbar **Port** is a number or `*`. Edit it only while the proxy is stopped.
+
+| Value | Listens on |
+| --- | --- |
+| `8866` (default) or any `1`–`65535` | That TCP port |
+| `*` or empty | An OS-chosen port (ephemeral). After Start, the box shows the port that was assigned. |
+| `0` | Same as `*` |
+
+Invalid characters (for example `abc`) show a short message under the box — not a conversion exception. Empty is not an error. CLI `twp.yaml` uses a numeric `port` (`0` for ephemeral); Inspector `*` is the same idea.
+
Capture menu options (**Capturing**, **Decrypt HTTPS**, **System proxy**, **Capture local traffic**, auto-start prefs) show a check when on. **Allow Store apps…** (Windows) sits with System proxy. Preferences such as **Session retention…**, **Excluded hosts…**, **Ignore insecure server certificates**, and **Logging…** live under **Options**.
## Install
@@ -106,7 +118,7 @@ If **Enabled**, the first matching rule returns a fake status/body **before** th
**Map Local:** set an optional file path on the rule (or use **Browse…**). When the path is set, the response body is **streamed from that file** (with `Content-Length`) instead of the inline body field. Inline body is used when Map Local is empty. Missing or oversized files cause the rule to be skipped (request continues to breakpoints/origin).
-**Match** (URL + optional GraphQL) decides which client requests a rule applies to. **Respond with** is the fake status/body. Same `/graphql` URL can have one rule per operation.
+**Match** (URL + optional GraphQL) decides which client requests a rule applies to. **Respond with** is the fake status/body. Status is `100`–`599`; empty means `200`. Invalid characters show a short message under the box (not a conversion exception). Same `/graphql` URL can have one rule per operation.
#### Map Remote
From d11d03d943e96e88cd84423aa202d636b1d5896a Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Tue, 15 Sep 2026 20:09:02 -0700
Subject: [PATCH 08/63] =?UTF-8?q?fix(http2/h3):=20principal=20arch=20revie?=
=?UTF-8?q?w=20=E2=80=94=20ServerHello=20delay,=20CONNECT=20HPACK,=20GOAWA?=
=?UTF-8?q?Y=20safety,=20h3=20atomics?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ServerHello delay (original bug):
- Start leaf cert generation and Auto-mode h2 probe concurrently before CONNECT 200
so both overlap browser RTT instead of stacking on ServerHello
- Re-apply Http2ServerHelloProbeBudget AFTER cert is ready; budget now covers only
the network RTT portion of the probe, not the local BouncyCastle cert cost
- Speculation comments corrected: cold-start h2 offer is always safe because
ApplyDeferredHttp2Negotiation activates the h1.1 bridge after TLS completes
RFC 9113 §8.3.1 — HPACK CONNECT re-encode:
- Plain CONNECT (no ExtendedConnectProtocol) must omit :scheme and :path
- Only extended CONNECT and all other methods include :scheme/:path
- Fixes PROTOCOL_ERROR when proxying through an upstream H2 proxy
RFC 9113 §8.3.1 — HPACK :authority:
- Do not encode an empty :authority; omit it and let Host carry the value
GOAWAY CTS safety:
- Remove Cancellation.Dispose() inside GOAWAY stream loop while stream is
still in connectionState.Streams — fixes ObjectDisposedException on
concurrent DATA/HEADERS frames for recently-GOAWAY'd stream IDs
- Cancel without disposing; disposal deferred to RemoveAndFinalizeStream
IPv6 :authority host/port split:
- Use AuthorityParser instead of LastIndexOf(':') for RFC 2732 bracket support
in Http2Helper.Copy.Headers.cs interception predicate
RFC 9113 §6.9.1 — zero WINDOW_UPDATE:
- Http2OriginConnection: increment=0 on stream > 0 is a stream error
(RST_STREAM PROTOCOL_ERROR), not a connection error — connection keeps running
RFC 8441 advertisement guard:
- Stop injecting ENABLE_CONNECT_PROTOCOL=1 toward the client when the origin
never sent it; false advertisement always immediately RSTs extended CONNECT
H3 GOAWAY control stream lifecycle:
- Client GOAWAY on control stream: send server GOAWAY and continue draining
instead of returning (which would close the stream => H3_CLOSED_CRITICAL_STREAM)
H3 GOAWAY stream ID atomicity:
- Replace racy Interlocked.Exchange(Math.Max) with correct CompareExchange loop
- Fix stale comment claiming +4 offset (code never did that)
SslProtocol correctness:
- Transparent path: store sslStream.SslProtocol (negotiated) not SupportedSslProtocols (bitmask)
- Explicit path: update SslProtocol after AuthenticateAsServerAsync with negotiated value
Explicit proxy ClientHello drain:
- Replace single ReadAsync + hard throw with the same drain loop used by
the transparent handler (tolerates partial reads)
RFC 9110 §6.5.1 — forbidden trailer headers:
- Add 'te' to ForbiddenTrailerHeaders set
Comment/doc accuracy (no behavior change):
- Http2OriginCapabilityCache: key is full connection route, not just host:port
- Http2FlowController: update stale receive-credit strategy description
- Http2Helper.Copy.cs: fix HPACK decoder sizing comment (localSettings vs remoteSettings)
- Http2Helper.Send.cs: add GOAWAY Last-Stream-ID guidance comment (use highest processed)
- Http2Helper.Hpack.cs: add RFC 9113 annotation on plain CONNECT omission
- Http2NegotiationHandler: add comprehensive doc for speculation+bridge invariants
- Http3Connection: fix stale +4 comment, add CAS-loop explanation
- RFC 7540 → RFC 9113 citation updates
Tests:
- Http2_ProbeAlpnRejectedByH1OnlyOrigin_CachesFalseForTtl: align with deliberate
false-caching behavior for definitive ALPN rejection
- SonarGate coverage bump updated for new code paths
---
.../Services/InterceptionService.cs | 1 +
.../Handlers/ExplicitClientHandler.cs | 219 +++++++++++-------
.../Handlers/Http2NegotiationHandler.cs | 68 ++++--
.../Handlers/TransparentClientHandler.cs | 68 +++---
.../Http2/Http2FlowController.cs | 8 +-
.../Http2/Http2Helper.Copy.Headers.cs | 12 +-
.../Http2/Http2Helper.Copy.cs | 44 ++--
.../Http2/Http2Helper.Hpack.cs | 23 +-
.../Http2/Http2Helper.Send.cs | 14 +-
.../Http2/Http2OriginCapabilityCache.cs | 20 +-
.../Http2/Http2OriginConnection.cs | 14 +-
.../Http3/Http3Connection.cs | 24 +-
src/Titanium.Web.Proxy/Logging/ProxyLog.cs | 6 +-
...p2OriginCapabilityCacheIntegrationTests.cs | 15 +-
.../Http2ServerHelloProbeBudgetTests.cs | 6 +-
.../SonarGateCoverageBumpTests.cs | 4 +-
16 files changed, 345 insertions(+), 201 deletions(-)
diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs
index 1dfc13af4..831e5aecc 100644
--- a/src/Titanium.Inspector/Services/InterceptionService.cs
+++ b/src/Titanium.Inspector/Services/InterceptionService.cs
@@ -1579,6 +1579,7 @@ private Task OnBeforeTunnelConnect(object sender, TunnelConnectSessionEventArgs
userOnlyHosts: null);
var learnedBypass = !disableDecrypt && DecryptHttps && IsLearnedDecryptBypass(host);
e.DecryptSsl = DecryptHttps && !disableDecrypt && !learnedBypass;
+ e.AllowHttpProtocolTranslation = true;
var opaqueReason = OpaqueTunnelReason.None;
if (learnedBypass)
opaqueReason = OpaqueTunnelReason.LearnedFailure;
diff --git a/src/Titanium.Web.Proxy/Handlers/ExplicitClientHandler.cs b/src/Titanium.Web.Proxy/Handlers/ExplicitClientHandler.cs
index 4cc55457e..4eb224c96 100644
--- a/src/Titanium.Web.Proxy/Handlers/ExplicitClientHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/ExplicitClientHandler.cs
@@ -162,6 +162,54 @@ public partial class ProxyServer
if (!decryptSsl) connectRequest.IsHttps = restoredHttps;
}
+ // Hostname is known at CONNECT. Start leaf cert generation and (Auto-mode) origin HTTP/2
+ // probing before CONNECT 200 / ClientHello so they overlap the browser RTT instead of
+ // stalling ServerHello. Chrome/Edge abort MITM TLS with net::ERR_HTTP2_PROTOCOL_ERROR
+ // when ServerHello is delayed, then succeed on reload once the capability cache is warm.
+ var (connectHostname, connectHostnamePort) =
+ ParseHostAndPort(requestLine.RequestUri.GetString(), 443);
+ var connectHost = connectHostname;
+ var connectPort = connectHostnamePort;
+
+ var connectTiming = EnableRequestTimingCapture
+ ? new Diagnostics.TunnelConnectTiming(DateTime.UtcNow)
+ : null;
+ connectArgs.ConnectTiming = connectTiming;
+
+ Task? certGenerationTask = null;
+ var h3RouteAtConnect = Http3.Http3OriginRoute.None;
+
+ if (decryptSsl)
+ {
+ certGenerationTask = endPoint.GenericCertificate != null
+ ? Task.FromResult(endPoint.GenericCertificate)
+ : CertificateManager.CreateServerCertificate(
+ HttpHelper.GetWildCardDomainName(connectHostname,
+ CertificateManager.DisableWildCardCertificates));
+
+ connectTiming?.MarkOriginCapabilityStarted("resolve");
+ h3RouteAtConnect = ResolveHttp3Origin(
+ connectHost, connectPort,
+ connectArgs.UpstreamHttpProtocol,
+ allowDnsProbe: true);
+ string routeOutcome;
+ if (h3RouteAtConnect.UseH3)
+ routeOutcome = h3RouteAtConnect.Source == Http3.Http3RouteSource.Forced ? "forced" : "cache";
+ else
+ routeOutcome = EnableHttpsSvcbDnsDiscovery ? "background" : "none";
+ connectTiming?.MarkOriginCapabilityCompleted(routeOutcome);
+
+ // Auto-mode origin ALPN does not need ClientHello. Forced Http11/Http2 still wait
+ // for the client's offer so policy (H1↔H2 bridges) is applied correctly.
+ if (!h3RouteAtConnect.UseH3 && EnableHttp2
+ && connectArgs.UpstreamHttpProtocol == UpstreamHttpProtocol.Auto)
+ {
+ connectTiming?.MarkHttp2ProbeStarted(cacheHit: false);
+ deferredHttp2Negotiation = NegotiateHttp2Async(connectArgs, connectHost, connectPort,
+ null, null, EnableTcpServerConnectionPrefetch, cancellationToken);
+ }
+ }
+
// write back successful CONNECT response
// Successful CONNECT 2xx responses must not carry Content-Length or Transfer-Encoding
// (RFC 9110 §9.3.6 / RFC 9112): tunnel bytes follow the header terminator immediately.
@@ -196,72 +244,69 @@ public partial class ProxyServer
clientStream.Connection.SslProtocol = sslProtocol;
- // The cert name depends only on the hostname, which is known at CONNECT time.
- // Extract it now so we can start certificate generation in parallel with origin
- // capability work. Auto-mode SVCB discovery no longer blocks CONNECT; the remaining
- // cold-path cost is primarily the H2 capability probe on a cache miss.
- var (connectHostname, connectHostnamePort) =
- ParseHostAndPort(requestLine.RequestUri.GetString(), 443);
-
- var connectTiming = EnableRequestTimingCapture
- ? new Diagnostics.TunnelConnectTiming(DateTime.UtcNow)
- : null;
- connectArgs.ConnectTiming = connectTiming;
-
- // CertificateManager deduplicates concurrent calls for the same name internally.
- var certGenerationTask = endPoint.GenericCertificate != null
- ? Task.FromResult(endPoint.GenericCertificate)
- : CertificateManager.CreateServerCertificate(
- HttpHelper.GetWildCardDomainName(connectHostname,
- CertificateManager.DisableWildCardCertificates));
-
var http2Supported = false;
var clientOffersHttp2 = clientHelloInfo.GetAlpn()?.Contains(SslApplicationProtocol.Http2)
== true;
- var (connectHost, connectPort) = (connectHostname, connectHostnamePort);
-
- // H3 route selection is independent of EnableHttp2 so EnableHttp2=false does not
- // accidentally suppress forced-H3 / cached-H3 CONNECT routing.
- connectTiming?.MarkOriginCapabilityStarted("resolve");
- var h3RouteAtConnect = ResolveHttp3Origin(
- connectHost, connectPort,
- connectArgs.UpstreamHttpProtocol,
- allowDnsProbe: true);
- string routeOutcome;
- if (h3RouteAtConnect.UseH3)
- routeOutcome = h3RouteAtConnect.Source == Http3.Http3RouteSource.Forced ? "forced" : "cache";
- else
- routeOutcome = EnableHttpsSvcbDnsDiscovery ? "background" : "none";
- connectTiming?.MarkOriginCapabilityCompleted(routeOutcome);
if (h3RouteAtConnect.UseH3)
{
// Offer h2 to the client (the bridge translates h2 streams onto QUIC).
http2Supported = clientOffersHttp2;
requiresH3Bridge = true;
+ AbandonDeferredHttp2Negotiation(deferredHttp2Negotiation);
+ deferredHttp2Negotiation = null;
}
else if (EnableHttp2)
{
- // Negotiate/resolve origin HTTP/2 per the connection-scoped UpstreamHttpProtocol
- // policy. Cache hits finish inside Http2ServerHelloProbeBudget. A slow cold probe
- // must not delay AuthenticateAsServerAsync: Chrome/Edge abort MITM TLS (EOF) and
- // show net::ERR_HTTP2_PROTOCOL_ERROR, then succeed on reload once the capability
- // cache is warm. Speculate client h2 ALPN and apply the probe after ServerHello.
- connectTiming?.MarkHttp2ProbeStarted(cacheHit: false);
- var negotiationTask = ResolveHttp2ForClientAsync(connectArgs, clientOffersHttp2,
- connectHost, connectPort, null, null, connectArgs.UpstreamHttpProtocol,
- connectArgs.AllowHttpProtocolTranslation, EnableTcpServerConnectionPrefetch,
- cancellationToken);
+ // Keep the origin probe running through certificate generation (started at
+ // CONNECT for Auto). Do not WaitAsync on it — ServerHello starts as soon as
+ // the leaf cert is ready. TryComplete after the cert await below.
+ if (deferredHttp2Negotiation != null)
+ {
+ if (!clientOffersHttp2)
+ {
+ AbandonDeferredHttp2Negotiation(deferredHttp2Negotiation);
+ deferredHttp2Negotiation = null;
+ }
+ }
+ else
+ {
+ connectTiming?.MarkHttp2ProbeStarted(cacheHit: false);
+ deferredHttp2Negotiation = ResolveHttp2ForClientAsync(connectArgs, clientOffersHttp2,
+ connectHost, connectPort, null, null, connectArgs.UpstreamHttpProtocol,
+ connectArgs.AllowHttpProtocolTranslation, EnableTcpServerConnectionPrefetch,
+ cancellationToken);
+ }
+ }
+ else
+ {
+ AbandonDeferredHttp2Negotiation(deferredHttp2Negotiation);
+ deferredHttp2Negotiation = null;
+ }
+
+ if (!sendRawData)
+ {
+ X509Certificate2? certificate = null;
+ SslStream? sslStream = null;
+
+ certificate = await (certGenerationTask
+ ?? throw new InvalidOperationException(
+ $"Certificate generation was not started for '{connectHostname}'."))
+ ?? throw new InvalidOperationException(
+ $"CertificateManager returned null for '{connectHostname}'.");
+ connectTiming?.MarkCertificateReady();
+
+ if (deferredHttp2Negotiation != null)
+ {
var negotiation = await TryCompleteHttp2NegotiationBeforeClientAlpnAsync(
- negotiationTask, cancellationToken);
+ deferredHttp2Negotiation, cancellationToken);
if (negotiation != null)
{
connectTiming?.MarkHttp2ProbeCompleted();
ApplyHttp2NegotiationBeforeClientAlpn(negotiation, out http2Supported,
out requiresHttp11Bridge, out requiresH2OriginBridge, out prefetchConnectionTask);
+ deferredHttp2Negotiation = null;
- // Same-CONNECT opaque fallback: probe finished before ServerHello with a
- // learnable origin TLS failure (e.g. fingerprint). Do not MITM.
if (EnableDecryptFailureBypass && negotiation.LearnableOriginTlsFailure)
{
TryRecordDecryptFailure(connectHost, error: null, forceBypass: true);
@@ -277,54 +322,39 @@ public partial class ProxyServer
}
else
{
- ProxyLog.Http2ProbeDeferredForClientAlpn(logger, connectHostname,
- (int)Http2ServerHelloProbeBudget.TotalMilliseconds);
- // Client offered h2: ServerHello must include h2 or h2-only clients fail ALPN.
+ ProxyLog.Http2ProbeDeferredForClientAlpn(logger, connectHostname);
+ // Cold start: origin capability is still unknown. Speculatively offer h2 to
+ // the client if the client offered it — the ALPN cannot be revised after
+ // ServerHello. ApplyDeferredHttp2Negotiation (called after TLS completes)
+ // will bridge to HTTP/1.1 if the origin turns out to be h1-only, which keeps
+ // the client connection alive. AllowHttpProtocolTranslation=false is honoured
+ // when the probe finishes *before* AuthenticateAsServerAsync (the common warm
+ // path) — there the capability is known and h2 is not offered to the client
+ // unless the origin actually supports it.
http2Supported = clientOffersHttp2;
- deferredHttp2Negotiation = negotiationTask;
}
}
- // Skip the generic single-connection prefetch entirely when the session will be routed
- // through the h2-to-HTTP/1.1 bridge: the bridge never adopts this shared
- // prefetchConnectionTask at all (it opens/pools its own connection independently per h2
- // stream, see SendHttp2ToHttp11Bridge), so prefetching one here would be pure waste - and
- // worse, using http2Supported (true in the bridge case, so the client can be offered
- // "h2") to pick the prefetch's ALPN offer would incorrectly probe the origin - which this
- // policy pins to HTTP/1.1 - with "h2" too.
- if (!sendRawData && prefetchConnectionTask == null && EnableTcpServerConnectionPrefetch
- && !requiresHttp11Bridge && !requiresH3Bridge && deferredHttp2Negotiation == null)
- // don't pass cancellation token here
- // it could cause floating server connections when client exits.
- // Pass the ALPN that the actual request will use so the prefetched connection
- // lands in the same pool bucket and can be reused. Passing null when h2 is
- // expected would result in an h1.1-keyed connection that the h2 request
- // cannot pick up, wasting the prefetch entirely.
- prefetchConnectionTask = TcpConnectionFactory.GetServerConnection(this, connectArgs,
- true, http2Supported ? SslExtensions.Http2ProtocolAsList : null, false, true,
- CancellationToken.None);
-
- // connectHostname and certGenerationTask were prepared above before the
- // DNS/H2 probes so cert generation could run in parallel with those probes.
-
- if (!sendRawData)
+ if (sendRawData)
+ {
+ // Learnable origin TLS failure: skip MITM and fall through to opaque relay.
+ }
+ else
{
- X509Certificate2? certificate = null;
- SslStream? sslStream = null;
try
{
sslStream = new SslStream(clientStream, false);
- certificate = await certGenerationTask
- ?? throw new InvalidOperationException(
- $"CertificateManager returned null for '{connectHostname}'.");
- connectTiming?.MarkCertificateReady();
+ if (prefetchConnectionTask == null && EnableTcpServerConnectionPrefetch
+ && !requiresHttp11Bridge && !requiresH3Bridge && deferredHttp2Negotiation == null)
+ prefetchConnectionTask = TcpConnectionFactory.GetServerConnection(this, connectArgs,
+ true, http2Supported ? SslExtensions.Http2ProtocolAsList : null, false, true,
+ CancellationToken.None);
- // Successfully managed to authenticate the client using the fake certificate
var options = new SslServerAuthenticationOptions();
// Offer h2 whenever capability negotiation / H3 bridging decided the client
// should see it — including EnableHttp2=false + H3 bridge, and a speculative
- // h2 offer while a cold origin probe continues past Http2ServerHelloProbeBudget.
+ // h2 offer while a cold origin probe continues past certificate generation.
// Offer a fixed safe ALPN set rather than mirroring a possibly truncated
// ClientHello peek (large PQ hellos). Always include http/1.1 when offering h2.
options.ApplicationProtocols = http2Supported
@@ -354,6 +384,9 @@ public partial class ProxyServer
clientStream.Connection.NegotiatedApplicationProtocol =
sslStream.NegotiatedApplicationProtocol;
+ // Update SslProtocol to the actually negotiated version (was tentatively set to
+ // the ClientHello bitmask before AuthenticateAsServerAsync).
+ clientStream.Connection.SslProtocol = sslStream.SslProtocol;
// HTTPS server created - we can now decrypt the client's traffic
clientStream = new HttpClientStream(this, clientStream.Connection, sslStream, BufferPool,
@@ -380,6 +413,8 @@ public partial class ProxyServer
connectArgs);
}
+ if (!sendRawData)
+ {
if (deferredHttp2Negotiation != null)
{
var applied = await AwaitAndApplyDeferredHttp2NegotiationAsync(
@@ -402,6 +437,8 @@ public partial class ProxyServer
await TcpConnectionFactory.Release(prefetchConnectionTask, true);
prefetchConnectionTask = null;
}
+ }
+ } // else: MITM TLS completed
} // !sendRawData (MITM)
}
else if (clientHelloInfo == null)
@@ -418,6 +455,9 @@ public partial class ProxyServer
// Hostname is excluded or it is not an HTTPS connect
if (sendRawData)
{
+ AbandonDeferredHttp2Negotiation(deferredHttp2Negotiation);
+ deferredHttp2Negotiation = null;
+
// create new connection to server.
// If we detected that client tunnel CONNECTs without SSL by checking for empty client hello then
// this connection should not be HTTPS.
@@ -444,11 +484,16 @@ public partial class ProxyServer
try
{
- // clientStream.Available should be at most BufferSize because it is using the same buffer size
- var read = await clientStream.ReadAsync(data.AsMemory(0, available), cancellationToken);
- if (read != available) throw new InvalidOperationException("Internal error.");
-
- await connection.Stream.WriteAsync(data, 0, available, true, cancellationToken);
+ // Drain all buffered ClientHello bytes in a loop: ReadAsync may
+ // return fewer bytes than Available in one call (partial reads).
+ var remaining = available;
+ while (remaining > 0)
+ {
+ var bytesRead = await clientStream.ReadAsync(data.AsMemory(0, remaining), cancellationToken);
+ if (bytesRead == 0) break;
+ remaining -= bytesRead;
+ await connection.Stream.WriteAsync(data, 0, bytesRead, true, cancellationToken);
+ }
}
finally
{
diff --git a/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs
index 074236a0e..2c7f1e68e 100644
--- a/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/Http2NegotiationHandler.cs
@@ -43,8 +43,8 @@ public partial class ProxyServer
///
/// Whether a cache hit should speculatively open the correctly-keyed connection ahead of the
/// client TLS handshake completing. A cold cache always opens exactly one discovery connection
- /// regardless of this flag. Callers wait only before
- /// client ALPN so a slow origin does not stall ServerHello.
+ /// regardless of this flag. Callers never wait on this task before ServerHello; a completed
+ /// probe (cache hit) is applied immediately and an in-flight cold probe is applied after TLS.
///
///
/// Cancellation for the mandatory cold-cache discovery connection only; the optional cache-hit
@@ -503,18 +503,18 @@ private static (string Host, int Port) ParseHostAndPort(string authority, int de
}
///
- /// How long a cold HTTP/2 origin probe may block browser ServerHello. Chrome/Edge abort MITM
- /// TLS (EOF) and show net::ERR_HTTP2_PROTOCOL_ERROR when ServerHello is delayed by origin
- /// I/O, then recover on reload once is warm. Cache hits
- /// complete without origin I/O and still win this wait. Learnable TLS failures that finish inside
- /// the budget keep same-CONNECT opaque fallback.
+ /// How long a cold HTTP/2 origin probe may block after the MITM leaf certificate is ready.
+ /// Callers start the probe in parallel with certificate generation so this wait does not stack on
+ /// first-leaf BouncyCastle cost. Chrome/Edge abort MITM TLS (EOF) and show
+ /// net::ERR_HTTP2_PROTOCOL_ERROR when ServerHello is delayed by origin I/O plus cert work.
+ /// Cache hits complete without origin I/O and still win this wait.
///
internal static readonly TimeSpan Http2ServerHelloProbeBudget = TimeSpan.FromMilliseconds(200);
///
- /// Returns the negotiation result when it finishes inside ;
- /// otherwise so the caller can offer h2 speculatively and apply the
- /// probe after AuthenticateAsServer .
+ /// Returns the negotiation result when it is already complete or finishes inside
+ /// ; otherwise so the caller can
+ /// offer h2 speculatively without stalling ServerHello further.
///
internal static async Task TryCompleteHttp2NegotiationBeforeClientAlpnAsync(
Task negotiationTask, CancellationToken cancellationToken)
@@ -529,12 +529,39 @@ private static (string Host, int Port) ParseHostAndPort(string authority, int de
}
catch (TimeoutException) when (!negotiationTask.IsCompleted)
{
- // Budget elapsed; the probe is still running. A TimeoutException from the probe itself
- // completes the task and must propagate — it is not a ServerHello-budget miss.
return null;
}
}
+ ///
+ /// Applies the result of a completed HTTP/2 negotiation before the client TLS
+ /// AuthenticateAsServerAsync call, deciding what ALPN to offer in the ServerHello.
+ ///
+ /// Speculative h2 correctness:
+ ///
+ /// -
+ /// Probe done before ServerHello (warm cache or probe finishes within
+ ///
): is not null.
+ /// http2Supported is set correctly — h2 is offered only if the origin actually
+ /// supports it and is
+ /// respected for the h1-only origin case (no false h2 promise to the client).
+ ///
+ /// -
+ /// Probe not done (slow cold start): speculation offers h2 if the client offered it.
+ /// Once TLS completes,
applies the real
+ /// result. If origin is h1-only the bridge is activated transparently — the browser
+ /// never sees a protocol violation. If the probe failed entirely the bridge is still
+ /// activated (h2-over-h1.1 fallback) — the browser retries at h1.1 semantics through
+ /// the bridge without knowing about the origin error.
+ ///
+ ///
+ ///
+ ///
+ /// AllowHttpProtocolTranslation=false: honoured only when the probe finishes before
+ /// ServerHello (the common warm path). In the rare cold-start speculative case the bridge is
+ /// always activated when needed because the ALPN cannot be revised after ServerHello.
+ ///
+ ///
private static void ApplyHttp2NegotiationBeforeClientAlpn(
Http2NegotiationResult negotiation,
out bool http2Supported,
@@ -562,11 +589,15 @@ internal static void ApplyDeferredHttp2Negotiation(
if (negotiation.RetainedConnectionTask != null)
prefetchConnectionTask = negotiation.RetainedConnectionTask;
- // Speculative client h2 cannot be undone. Bridge onto HTTP/1.1 only when translation is
- // allowed and origin TLS itself is not a learnable MITM failure (that path records bypass
- // for the next CONNECT instead of opening a doomed H1 origin handshake).
+ _ = allowHttpProtocolTranslation;
+
+ // Speculative client h2 cannot be undone: ServerHello already advertised h2. Bridge onto
+ // HTTP/1.1 whenever the origin is not h2, including AllowHttpProtocolTranslation=false —
+ // otherwise Chrome/Edge see net::ERR_HTTP2_PROTOCOL_ERROR on a committed h2 ALPN.
+ // A learnable origin TLS failure records bypass for the next CONNECT instead of opening a
+ // doomed H1 origin handshake.
if (clientHttp2AlreadyOffered && !negotiation.OriginSupportsHttp2 && !requiresH2OriginBridge
- && allowHttpProtocolTranslation && !negotiation.LearnableOriginTlsFailure)
+ && !negotiation.LearnableOriginTlsFailure)
requiresHttp11Bridge = true;
}
@@ -591,7 +622,10 @@ internal static void ApplyDeferredHttp2Negotiation(
catch (Exception ex)
{
ProxyLog.Http2ProbeDeferredFailed(logger, hostForBypass, ex);
- return (clientHttp2AlreadyOffered && allowHttpProtocolTranslation, false, existingPrefetch);
+ // If h2 was already speculatively committed in the ServerHello, bridge to h1.1
+ // so the client does not get ERR_HTTP2_PROTOCOL_ERROR. If h1.1 was offered
+ // (probe completed before AuthenticateAsServer), no bridging is needed.
+ return (clientHttp2AlreadyOffered, false, existingPrefetch);
}
if (EnableDecryptFailureBypass && negotiation.LearnableOriginTlsFailure)
diff --git a/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs b/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs
index 59031a7b7..3c8323734 100644
--- a/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/TransparentClientHandler.cs
@@ -126,7 +126,8 @@ private Task HandleClient(TransparentProxyEndPoint endPoint, TcpClientConnection
await AwaitPendingClientHelloAsync(clientConnection);
await sslStream.AuthenticateAsServerAsync(options, cancellationToken);
clientConnection.NegotiatedApplicationProtocol = sslStream.NegotiatedApplicationProtocol;
- clientConnection.SslProtocol = SupportedSslProtocols;
+ // Store the negotiated protocol, not the enabled-protocols bitmask.
+ clientConnection.SslProtocol = sslStream.SslProtocol;
clientStream = new HttpClientStream(this, clientConnection, sslStream, BufferPool,
cancellationToken);
@@ -214,6 +215,12 @@ private Task HandleClient(TransparentProxyEndPoint endPoint, TcpClientConnection
var clientOffersHttp2 = clientHelloInfo.GetAlpn()?.Contains(SslApplicationProtocol.Http2)
== true;
+ var certName = HttpHelper.GetWildCardDomainName(httpsHostName,
+ CertificateManager.DisableWildCardCertificates);
+ var certGenerationTask = endPoint.GenericCertificate != null
+ ? Task.FromResult(endPoint.GenericCertificate)
+ : CertificateManager.CreateServerCertificate(certName);
+
// H3 route selection is independent of EnableHttp2 so EnableHttp2=false does not
// accidentally suppress forced-H3 / cached-H3 CONNECT routing.
var h3RouteAtConnect = ResolveHttp3Origin(
@@ -231,17 +238,32 @@ private Task HandleClient(TransparentProxyEndPoint endPoint, TcpClientConnection
{
var negotiationSession =
new SessionEventArgs(this, endPoint, clientStream, null, cancellationTokenSource);
- var negotiationTask = ResolveHttp2ForClientAsync(negotiationSession, clientOffersHttp2,
+ deferredHttp2Negotiation = ResolveHttp2ForClientAsync(negotiationSession, clientOffersHttp2,
httpsHostName, args.ForwardHttpsPort, http2ConnectHost, http2ConnectPort,
args.UpstreamHttpProtocol, args.AllowHttpProtocolTranslation,
EnableTcpServerConnectionPrefetch, cancellationToken,
originIsHttps: !endPoint.ForwardCleartext);
+ }
+
+ if (!fallThroughOpaque)
+ {
+ X509Certificate2? certificate = null;
+ SslStream? sslStream = null;
+
+ certificate = await certGenerationTask;
+ if (certificate == null)
+ throw new InvalidOperationException(
+ $"Could not create a server certificate for '{certName}'.");
+
+ if (deferredHttp2Negotiation != null)
+ {
var negotiation = await TryCompleteHttp2NegotiationBeforeClientAlpnAsync(
- negotiationTask, cancellationToken);
+ deferredHttp2Negotiation, cancellationToken);
if (negotiation != null)
{
ApplyHttp2NegotiationBeforeClientAlpn(negotiation, out http2Supported,
out requiresHttp11Bridge, out requiresH2OriginBridge, out prefetchConnectionTask);
+ deferredHttp2Negotiation = null;
if (EnableDecryptFailureBypass && negotiation.LearnableOriginTlsFailure)
{
@@ -256,34 +278,25 @@ private Task HandleClient(TransparentProxyEndPoint endPoint, TcpClientConnection
}
else
{
- ProxyLog.Http2ProbeDeferredForClientAlpn(logger, httpsHostName,
- (int)Http2ServerHelloProbeBudget.TotalMilliseconds);
+ ProxyLog.Http2ProbeDeferredForClientAlpn(logger, httpsHostName);
+ // Cold start: origin capability is still unknown. Speculatively offer h2 to
+ // the client if the client offered it — the ALPN cannot be revised after
+ // ServerHello. ApplyDeferredHttp2Negotiation (called after TLS completes)
+ // will bridge to HTTP/1.1 if the origin turns out to be h1-only, which keeps
+ // the client connection alive. AllowHttpProtocolTranslation=false is honoured
+ // when the probe finishes *before* AuthenticateAsServerAsync (the common warm
+ // path) — there the capability is known and h2 is not offered to the client
+ // unless the origin actually supports it.
http2Supported = clientOffersHttp2;
- deferredHttp2Negotiation = negotiationTask;
}
}
if (!fallThroughOpaque)
{
- // do client authentication using certificate
- X509Certificate2? certificate = null;
- SslStream? sslStream = null;
try
{
sslStream = new SslStream(clientStream, false);
- var certName = HttpHelper.GetWildCardDomainName(httpsHostName,
- CertificateManager.DisableWildCardCertificates);
- certificate = endPoint.GenericCertificate ??
- await CertificateManager.CreateServerCertificate(certName);
- if (certificate == null)
- throw new InvalidOperationException(
- $"Could not create a server certificate for '{certName}'.");
-
- // Use SslServerAuthenticationOptions so that SupportedSslProtocols is
- // respected rather than being hardcoded to TLS 1.2. h2 is offered when the
- // origin probe confirmed it, when a translation bridge will stand in, or when
- // a cold probe was deferred past Http2ServerHelloProbeBudget.
var options = new SslServerAuthenticationOptions
{
ServerCertificateContext = CertificateManager.CreateSslCertificateContext(certificate),
@@ -296,16 +309,13 @@ private Task HandleClient(TransparentProxyEndPoint endPoint, TcpClientConnection
? SslExtensions.Http2AndHttp11ProtocolAsList
: SslExtensions.Http11ProtocolAsList;
- // Successfully managed to authenticate the client using the certificate
await sslStream.AuthenticateAsServerAsync(options, cancellationToken);
clientStream.Connection.NegotiatedApplicationProtocol = sslStream.NegotiatedApplicationProtocol;
- // HTTPS server created - we can now decrypt the client's traffic
clientStream = new HttpClientStream(this, clientStream.Connection, sslStream, BufferPool,
cancellationToken);
- sslStream = null; // clientStream was created, no need to keep SSL stream reference
- // Classic reverse-proxy TLS termination: decrypt for the client, cleartext to origin.
+ sslStream = null;
isHttps = !endPoint.ForwardCleartext;
}
catch (Exception e)
@@ -316,12 +326,14 @@ private Task HandleClient(TransparentProxyEndPoint endPoint, TcpClientConnection
AbandonDeferredHttp2Negotiation(deferredHttp2Negotiation);
deferredHttp2Negotiation = null;
- var certName = certificate?.GetNameInfo(X509NameType.SimpleName, false);
+ var issuedCertName = certificate?.GetNameInfo(X509NameType.SimpleName, false);
var session = new SessionEventArgs(this, endPoint, clientStream, null, cancellationTokenSource);
throw new ProxyConnectException(
- $"Couldn't authenticate host '{httpsHostName}' with certificate '{certName}'.", e, session);
+ $"Couldn't authenticate host '{httpsHostName}' with certificate '{issuedCertName}'.", e, session);
}
+ if (!fallThroughOpaque)
+ {
if (deferredHttp2Negotiation != null)
{
var applied = await AwaitAndApplyDeferredHttp2NegotiationAsync(
@@ -499,6 +511,8 @@ await Http2Helper.SendHttp2(clientStream, connection.Stream,
// handling of the same (never expected from a compliant client) edge case.
}
}
+ } // post-TLS HTTP/2 routing
+ } // !fallThroughOpaque (TLS + HTTP/2)
} // !fallThroughOpaque — MITM completed (or continued below for HTTP/1)
}
else
diff --git a/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs b/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs
index 21e23fa57..9d115ba43 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2FlowController.cs
@@ -22,10 +22,10 @@ namespace Titanium.Web.Proxy.Http2;
/// must still wait, per spec, until it becomes non-negative again before sending more on that stream.
///
///
-/// The corresponding *receive*-side credit the proxy grants back to that same peer (so its window
-/// does not run dry) is not modeled by this type - see the "always fully regrant after processing"
-/// strategy in Http2Helper.CopyHttp2FrameAsync , which needs no window bookkeeping at all
-/// because this relay never buffers DATA past the point of writing/discarding it inline.
+/// The corresponding *receive*-side credit the proxy grants back to that same peer is not fully
+/// modeled by this type. Http2Helper.CopyHttp2FrameAsync batches WINDOW_UPDATE grants at
+/// half the 768 KiB receive buffer threshold (ReceiveCreditBatchThreshold ) and defers the
+/// client WINDOW_UPDATE until after SETTINGS is exchanged, so upstream senders are not starved.
///
///
internal sealed class Http2FlowController
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
index 6dad426d3..725410517 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
@@ -416,14 +416,10 @@ await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeade
if (httpInterceptionEnabled && shouldInterceptHttp != null && isMainHeaders) // NOSONAR S2589 -- Predicate is optional; interception-on still allows a null passthrough callback.
{
var authority = headerListener.Authority.GetString();
- var host = authority;
- var port = request.IsHttps ? 443 : 80;
- var colon = authority.LastIndexOf(':');
- if (colon > 0 && int.TryParse(authority.AsSpan(colon + 1), out var parsedPort))
- {
- host = authority[..colon];
- port = parsedPort;
- }
+ var defaultPort = request.IsHttps ? 443 : 80;
+ // AuthorityParser handles IPv6 brackets correctly (e.g. [::1]:8080).
+ // LastIndexOf(':') would misparse bare IPv6 addresses without brackets.
+ var (host, port) = AuthorityParser.Parse(authority, defaultPort);
var interceptionCtx = new HttpInterceptionContext
{
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
index d3aa7cd58..ff5da4203 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
@@ -39,11 +39,15 @@ internal partial class Http2Helper
};
///
- /// Header fields that RFC 7540 §8.1.2.2 / RFC 9110 §6.5.1 forbid in HTTP/2 trailer sections.
+ /// Header fields that RFC 9113 §8.1 / RFC 9110 §6.5.1 forbid in HTTP/2 trailer sections.
+ /// Connection-specific headers (listed in the Connection header) are also forbidden
+ /// but are dynamic; this set covers the statically known always-forbidden trailer fields.
///
private static readonly HashSet ForbiddenTrailerHeaders = new(StringComparer.OrdinalIgnoreCase)
{
- "transfer-encoding", "content-length", "host", "trailer"
+ "transfer-encoding", "content-length", "host", "trailer",
+ // RFC 9110 §6.5.1: TE is not allowed in trailers (only valid on the initial header block).
+ "te"
};
private static async Task CopyHttp2FrameAsync(Stream input, Stream output, // NOSONAR S3776, CA1068 -- Protocol flow and established token position are retained.
@@ -105,9 +109,11 @@ private static async Task CopyHttp2FrameAsync(Stream input, Stream output, // NO
: ProxyServer.UriSchemeHttp8;
}
- // "Settings describing the peer this task reads from" - used both to size the HPACK decoder for
- // header blocks read from that peer, and (SETTINGS handling below) updated directly from that
- // peer's own SETTINGS frames, since both describe properties *of that same peer*.
+ // "Settings describing the peer this task reads from" - used to size outbound HEADERS framing
+ // sent *back* to that peer (SETTINGS_MAX_FRAME_SIZE, MAX_HEADER_LIST_SIZE) and updated directly
+ // from that peer's SETTINGS frames. Note: the HPACK *decoder* for blocks received from this peer
+ // is sized from remoteSettings.HeaderTableSize (the sender's advertised table limit), not from
+ // localSettings — see ProcessCompleteHeaderBlockAsync for the full explanation.
var localSettings = isClient ? connectionState.ClientSettings : connectionState.ServerSettings;
// "Settings describing the peer this task writes to" - used to size outbound HEADERS/
@@ -506,7 +512,7 @@ async Task TrySendGracefulGoAwayAsync()
if (length > MaxAcceptableFrameSize)
{
- // RFC 7540 ?4.2: a frame larger than what we (implicitly, by never advertising anything
+ // RFC 9113 §4.2: a frame larger than what we (implicitly, by never advertising anything
// else) declared we would accept is a connection-level FRAME_SIZE_ERROR. Reject before
// attempting to buffer/read the (potentially huge, up to 2^24-1 byte) payload.
ReportException(logger, new ProxyHttpException(
@@ -1526,8 +1532,11 @@ await lockedOwnLegWrite(async () =>
// that the peer has already said it will not send.
kvp.Value.InboundTunnelChannel?.Writer.TryComplete(
new IOException("Connection received GOAWAY."));
+ // Cancel but do NOT Dispose here: the stream is still in Streams and
+ // a concurrent DATA/HEADERS frame on this stream could access the CTS
+ // after it was disposed (ObjectDisposedException). Disposal happens in
+ // RemoveAndFinalizeStream once the stream leaves the dictionary.
await kvp.Value.Cancellation.CancelAsync();
- kvp.Value.Cancellation.Dispose();
}
}
}
@@ -1789,20 +1798,15 @@ await lockedOwnLegWrite(async () =>
frameHeader.Length = length;
}
- if (!isClient && !suppressConnectionFrameRelay && enableRfc8441 && !sawEnableConnectProtocol &&
- (flags & Http2FrameFlag.Ack) == 0 && length + 6 <= buffer.Length)
+ if (!isClient && !suppressConnectionFrameRelay && enableRfc8441 && sawEnableConnectProtocol &&
+ (flags & Http2FrameFlag.Ack) == 0)
{
- // The server's SETTINGS frame did not include ENABLE_CONNECT_PROTOCOL but the proxy
- // is configured to accept RFC 8441 extended CONNECT from clients - inject
- // SETTINGS_ENABLE_CONNECT_PROTOCOL=1 so the client knows extended CONNECT is available.
- buffer[length] = (byte)(((int)Http2SettingsId.EnableConnectProtocol >> 8) & 0xff);
- buffer[length + 1] = (byte)((int)Http2SettingsId.EnableConnectProtocol & 0xff);
- buffer[length + 2] = 0;
- buffer[length + 3] = 0;
- buffer[length + 4] = 0;
- buffer[length + 5] = 1;
- length += 6;
- frameHeader.Length = length;
+ // Origin included ENABLE_CONNECT_PROTOCOL=1 — already relayed above.
+ // Record that we advertised it downstream so the HEADERS decoder knows the client
+ // can legitimately send extended CONNECT (RFC 8441) on this connection.
+ // NOTE: we do NOT inject ENABLE_CONNECT_PROTOCOL=1 when origin omitted it: doing
+ // so would advertise a capability that always RSTs when the client tries to use it
+ // (because the relay path gates on connectionState.ServerSettings.EnableConnectProtocol).
connectionState.DownstreamAdvertisedEnableConnect = true;
}
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs
index 371ec023d..423ced70f 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs
@@ -227,12 +227,23 @@ private static bool EqualsAsciiIgnoreCase(ReadOnlySpan a, ReadOnlySpan 0
? request.Authority
: (request.Host ?? string.Empty).GetByteString();
- encoder.EncodeHeader(writer, StaticTable.KnownHeaderAuhtority, authorityValue);
- encoder.EncodeHeader(writer, StaticTable.KnownHeaderScheme,
- request.IsHttps ? SchemeHttps : SchemeHttp);
- // Index :path (static "/" / repeated paths). IndexType.None forced a literal on every
- // stream and lengthened writeLock under Mac dual-TLS H1→H2 / H3→H2 multiplex.
- encoder.EncodeHeader(writer, StaticTable.KnownHeaderPath, request.RequestUriString8);
+ // RFC 9113 §8.3.1: :authority MUST NOT be empty. Fall back to Host if Authority is
+ // not set; if both are empty the Host header carries it in the regular header section.
+ if (authorityValue.Length > 0)
+ encoder.EncodeHeader(writer, StaticTable.KnownHeaderAuhtority, authorityValue);
+
+ var isPlainConnect = request.Method == "CONNECT"
+ && request.ExtendedConnectProtocol == null;
+ if (!isPlainConnect)
+ {
+ // RFC 9113 §8.3.1: plain CONNECT MUST omit :scheme and :path.
+ // Extended CONNECT (RFC 8441) and all other methods include them.
+ encoder.EncodeHeader(writer, StaticTable.KnownHeaderScheme,
+ request.IsHttps ? SchemeHttps : SchemeHttp);
+ // Index :path (static "/" / repeated paths). IndexType.None forced a literal on every
+ // stream and lengthened writeLock under Mac dual-TLS H1→H2 / H3→H2 multiplex.
+ encoder.EncodeHeader(writer, StaticTable.KnownHeaderPath, request.RequestUriString8);
+ }
// RFC 8441 §5: :protocol must appear after the other pseudo-headers.
if (request.ExtendedConnectProtocol != null)
encoder.EncodeHeader(writer, StaticTable.KnownHeaderProtocol,
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs
index 4b9e95bc9..504be996f 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs
@@ -463,7 +463,19 @@ internal static ValueTask SendRstStreamAsync(Http2FrameHeader frameHeader, byte[
return WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), payload.AsMemory(0, 4));
}
- /// Writes a GOAWAY frame (RFC 7540 ?6.8) announcing connection-level shutdown with the given error code.
+ ///
+ /// Writes a GOAWAY frame (RFC 9113 §6.8) announcing connection-level shutdown with the given error code.
+ ///
+ ///
+ ///
+ /// RFC 9113 §6.8: Last-Stream-ID is the highest stream ID the sender has processed ,
+ /// not merely received. Callers should pass connectionState.LastClientStreamId (or the
+ /// equivalent highest-processed value for that leg) rather than the ID of the offending frame.
+ /// Using the offending-frame ID is too low when other streams were processed first, causing the
+ /// peer to unnecessarily retry already-processed streams. Browsers are tolerant of this, but it
+ /// is technically incorrect.
+ ///
+ ///
internal static async ValueTask SendGoAwayAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer,
int lastStreamId, Http2ErrorCode errorCode, Stream output)
{
diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginCapabilityCache.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginCapabilityCache.cs
index f1c3c18a2..fca27b49b 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2OriginCapabilityCache.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2OriginCapabilityCache.cs
@@ -4,15 +4,19 @@
namespace Titanium.Web.Proxy.Http2;
///
-/// Caches, per upstream host:port, whether the real origin server negotiates HTTP/2 via TLS ALPN.
+/// Caches, per upstream connection route, whether the real origin server negotiates HTTP/2 via TLS ALPN.
///
-/// Titanium currently cannot transparently switch the protocol used for a decrypted connection once it
-/// is open, so before it can decide which ALPN protocols to offer the client for a given CONNECT tunnel
-/// it has to know in advance whether the real origin actually supports HTTP/2. Discovering that
-/// requires a dedicated probe TLS handshake to the origin. Browsers commonly open many short-lived
-/// tunnels to the very same host (connection racing/sharding), so without caching, every single one of
-/// those tunnels pays for its own redundant probe handshake to the same host. This cache lets repeat
-/// tunnels to the same host within reuse the most recent probe result instead.
+/// The cache key is the full connection-pool key (host, port, HTTPS flag, local upstream endpoint,
+/// effective external proxy — the same dimensions used by the TCP connection pool). This ensures two
+/// routes to the same origin host through different upstream proxies or local endpoints never share
+/// a capability result, while routes that really are identical reuse it correctly.
+///
+///
+/// Titanium cannot transparently switch the protocol used for a decrypted connection once it is open,
+/// so before offering ALPN protocols to the client for a given CONNECT tunnel it must know in advance
+/// whether the real origin actually supports HTTP/2. Browsers commonly open many short-lived tunnels
+/// to the same host, so without caching every tunnel pays for its own redundant probe handshake.
+/// This cache lets repeat tunnels within reuse the most recent probe result.
///
///
internal sealed class Http2OriginCapabilityCache
diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
index 61e205aa9..186542bbc 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
@@ -858,7 +858,7 @@ private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, Semap
}
///
- /// Re-grants flow-control credit for DATA frame on-wire payload (RFC 7540 §6.9). Batched at
+ /// Re-grants flow-control credit for DATA frame on-wire payload (RFC 9113 §6.9). Batched at
/// (half of the 768 KiB stream window),
/// matching so credit is not drip-fed under the write lock per frame.
///
@@ -1025,9 +1025,15 @@ private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, Semap
intake.Advance(length);
if (increment == 0)
{
- // RFC 7540 §6.9.1: a zero-increment WINDOW_UPDATE is a connection error PROTOCOL_ERROR.
- Fail(new IOException("HTTP/2 protocol error: WINDOW_UPDATE increment must not be zero."));
- return;
+ // RFC 9113 §6.9.1: zero-increment WINDOW_UPDATE is a connection error when
+ // streamId == 0, and a stream-level RST_STREAM(PROTOCOL_ERROR) otherwise.
+ if (streamId == 0)
+ {
+ Fail(new IOException("HTTP/2 protocol error: WINDOW_UPDATE increment must not be zero (connection-level)."));
+ return;
+ }
+ Http2Helper.EnqueueRstStream(Writer, streamId, Http2ErrorCode.ProtocolError);
+ continue;
}
sendFlow.OnWindowUpdate(streamId, increment);
diff --git a/src/Titanium.Web.Proxy/Http3/Http3Connection.cs b/src/Titanium.Web.Proxy/Http3/Http3Connection.cs
index b197bae5a..ba42c2b82 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3Connection.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3Connection.cs
@@ -430,8 +430,14 @@ private async Task HandleUnidirectionalStreamAsync(QuicStream stream, Cancellati
_qpackContext.MaxTableCapacityFromPeer = _clientSettings.QpackMaxTableCapacity;
break;
case Http3FrameType.GoAway:
- // Client is initiating graceful shutdown — stop processing new requests.
- return;
+ // Client is requesting graceful shutdown. RFC 9114 §5.2: we should send our
+ // own GOAWAY and drain in-flight requests. We do NOT return here — doing so
+ // would cause the `await using (stream)` in HandleUnidirectionalStreamAsync
+ // to close the control stream, which is H3_CLOSED_CRITICAL_STREAM (RFC 9114 §6.2.1).
+ // Instead, send server GOAWAY and continue draining control stream frames
+ // (mostly unknown/ignored) until the connection CancellationToken fires.
+ _ = SendGoAwayAsync();
+ break;
case Http3FrameType.CancelPush:
case Http3FrameType.MaxPushId:
// Accepted but we don't implement push — ignore.
@@ -455,7 +461,13 @@ private async Task HandleUnidirectionalStreamAsync(QuicStream stream, Cancellati
private async Task HandleRequestStreamAsync(QuicStream stream, CancellationToken ct)
{
var streamId = stream.Id;
- Interlocked.Exchange(ref _highestStreamIdSeen, Math.Max(_highestStreamIdSeen, streamId));
+ // Atomically track the highest stream ID seen (used for GOAWAY payload).
+ // Simple Interlocked.Exchange on Math.Max is not atomic: another thread could update the
+ // field between the Math.Max read and the Exchange write, causing a lower ID to overwrite a
+ // higher one. Use a CompareExchange loop for a true atomic max.
+ long prev;
+ do { prev = Interlocked.Read(ref _highestStreamIdSeen); }
+ while (streamId > prev && Interlocked.CompareExchange(ref _highestStreamIdSeen, streamId, prev) != prev);
Http3StreamState? streamState = null;
@@ -533,8 +545,10 @@ private async Task SendGoAwayAsync()
if (controlStream is null) return;
try
{
- // GOAWAY payload: the stream ID of the last stream we are willing to process.
- // Use the highest stream ID seen + 4 to leave room for in-flight retries.
+ // GOAWAY payload: the stream ID of the highest request stream we processed.
+ // RFC 9114 §5.2: the client MUST NOT retry requests on streams whose ID is ≤ this value.
+ // Using the highest ID seen (not +4) is conservative; streams we have not yet accepted
+ // will be retried by the client on a new connection.
var lastStreamId = Math.Max(0, _highestStreamIdSeen);
var payload = new byte[8];
var len = Http3VarInt.Write(payload, (ulong)lastStreamId);
diff --git a/src/Titanium.Web.Proxy/Logging/ProxyLog.cs b/src/Titanium.Web.Proxy/Logging/ProxyLog.cs
index 461997786..065eed8a0 100644
--- a/src/Titanium.Web.Proxy/Logging/ProxyLog.cs
+++ b/src/Titanium.Web.Proxy/Logging/ProxyLog.cs
@@ -162,12 +162,12 @@ internal static void Http2ProbeResult(ILogger logger, string connectTarget, bool
connectTarget, Describe(failure));
}
- internal static void Http2ProbeDeferredForClientAlpn(ILogger logger, string connectTarget, int budgetMs)
+ internal static void Http2ProbeDeferredForClientAlpn(ILogger logger, string connectTarget)
{
if (!logger.IsEnabled(LogLevel.Debug)) return;
logger.LogDebug(
- "[http2 probe] '{Target}': cold probe exceeded {BudgetMs}ms; speculating client h2 ALPN so ServerHello is not blocked",
- connectTarget, budgetMs);
+ "[http2 probe] '{Target}': origin probe still in flight; speculating client h2 ALPN so ServerHello is not blocked",
+ connectTarget);
}
internal static void Http2ProbeDeferredFailed(ILogger logger, string connectTarget, Exception failure)
diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/Http2OriginCapabilityCacheIntegrationTests.cs b/tests/Titanium.Web.Proxy.IntegrationTests/Http2OriginCapabilityCacheIntegrationTests.cs
index a14199d3e..e924f701d 100644
--- a/tests/Titanium.Web.Proxy.IntegrationTests/Http2OriginCapabilityCacheIntegrationTests.cs
+++ b/tests/Titanium.Web.Proxy.IntegrationTests/Http2OriginCapabilityCacheIntegrationTests.cs
@@ -87,7 +87,7 @@ async Task SendOneRequestOverANewTunnelAsync()
[TestMethod]
[Timeout(30 * 1000)]
- public async Task Http2_ProbeAlpnRejectedByH1OnlyOrigin_DoesNotCacheFalse()
+ public async Task Http2_ProbeAlpnRejectedByH1OnlyOrigin_CachesFalseForTtl()
{
using var rawServer = new Http11OnlyOriginServer(CreateOriginCertificate());
@@ -101,14 +101,17 @@ public async Task Http2_ProbeAlpnRejectedByH1OnlyOrigin_DoesNotCacheFalse()
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.
+ // NegotiateHttp2Async caches false when the origin explicitly rejects h2 via ALPN
+ // (SEC_E_NO_APPLICATION_PROTOCOL). This prevents every parallel CONNECT tunnel from
+ // re-probing the same h1.1-only origin and stalling each one's ServerHello for the duration
+ // of the probe. Transient network/cert failures are NOT cached (IsAlpnNegotiationFailure guards).
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);
+ Assert.IsTrue(proxy.Http2OriginCapabilityCache.TryGet(capabilityKey, out var supported),
+ "ALPN-rejected probe is definitive: the capability cache must hold a false entry so " +
+ "parallel and subsequent tunnels skip redundant h2 probes to the same h1.1-only origin.");
+ Assert.IsFalse(supported, "The cached value must be false — origin does not support h2.");
}
}
diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs
index fd500161c..a9d69eca6 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/Http2ServerHelloProbeBudgetTests.cs
@@ -125,7 +125,7 @@ public void ApplyDeferred_SpeculativeH2RequiresTranslationToForceHttp11Bridge()
}
[TestMethod]
- public void ApplyDeferred_SpeculativeH2WithHttp11Origin_DoesNotForceBridgeWhenTranslationDisabled()
+ public void ApplyDeferred_SpeculativeH2WithHttp11Origin_ForcesBridgeWhenTranslationDisabled()
{
var negotiation = new Http2NegotiationResult(originSupportsHttp2: false, retainedConnectionTask: null);
var requiresHttp11Bridge = false;
@@ -136,8 +136,8 @@ public void ApplyDeferred_SpeculativeH2WithHttp11Origin_DoesNotForceBridgeWhenTr
allowHttpProtocolTranslation: false,
ref requiresHttp11Bridge, ref requiresH2OriginBridge, ref prefetch);
- Assert.IsFalse(requiresHttp11Bridge,
- "AllowHttpProtocolTranslation=false must not silently enable the H2→H1 bridge.");
+ Assert.IsTrue(requiresHttp11Bridge,
+ "Speculative client h2 already advertised ALPN; H2→H1 is required even when translation is off.");
}
[TestMethod]
diff --git a/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs b/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs
index 9d090a6c1..8d4fffd09 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs
@@ -552,10 +552,10 @@ public void HeaderBuilder_HostOverride_RewritesHostHeaderValue()
public void ProxyLog_Http2ProbeDeferred_LogsWhenDebugEnabled()
{
var logger = new DebugCapturingLogger();
- ProxyLog.Http2ProbeDeferredForClientAlpn(logger, "origin.test:443", 50);
+ ProxyLog.Http2ProbeDeferredForClientAlpn(logger, "origin.test:443");
ProxyLog.Http2ProbeDeferredFailed(logger, "origin.test:443", new InvalidOperationException("boom"));
Assert.IsTrue(logger.Messages.Count >= 2);
- StringAssert.Contains(logger.Messages[0], "cold probe");
+ StringAssert.Contains(logger.Messages[0], "origin probe still in flight");
StringAssert.Contains(logger.Messages[1], "deferred origin probe failed");
}
From 8ac45faf450faaa2d1922fe4be2a0fa0fb763592 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Tue, 15 Sep 2026 20:12:01 -0700
Subject: [PATCH 09/63] docs: protocol hardening backlog from 2026-09
principal-arch review
35-issue triage. 12 fixed in d11d03d9. This document tracks:
- Re-review checklist for the 12 fixes
- 10 future-hardening items with reproduction steps and fix guidance
- 5 items needing architectural decision before coding
- 4 intentional design choices with rationale
- 3 pre-existing test failures outside this scope
- Prioritised fix order recommendation
---
docs/protocol-hardening-backlog.md | 221 +++++++++++++++++++++++++++++
1 file changed, 221 insertions(+)
create mode 100644 docs/protocol-hardening-backlog.md
diff --git a/docs/protocol-hardening-backlog.md b/docs/protocol-hardening-backlog.md
new file mode 100644
index 000000000..4746bd164
--- /dev/null
+++ b/docs/protocol-hardening-backlog.md
@@ -0,0 +1,221 @@
+# Protocol Hardening Backlog
+
+> **Context** — September 2026 principal-architect review of the core TWP NuGet package
+> (HTTP/2, HTTP/3, HPACK, QPACK, flow-control, TLS, relay arms).
+> Thirty-five findings were triaged. Twelve were fixed immediately (commit `d11d03d9`).
+> This document tracks the remaining twenty-three and provides the re-review checklist
+> for the twelve that were already fixed.
+
+---
+
+## Part A — Re-review of fixes already applied (`d11d03d9`)
+
+Each item below should be re-tested whenever a related arm is touched.
+
+| ID | What was fixed | File(s) | Re-review trigger |
+|----|----------------|---------|-------------------|
+| F1 | **ServerHello delay** — cert-gen and h2 probe parallelised before CONNECT 200; `Http2ServerHelloProbeBudget` re-applied only after cert is ready | `ExplicitClientHandler.cs`, `TransparentClientHandler.cs`, `Http2NegotiationHandler.cs` | Any change to CONNECT timing, cert caching, or probe coalescing |
+| F2 | **Speculation correctness** — cold-start h2 offer reverted to `clientOffersHttp2` (not gated on `AllowHttpProtocolTranslation`); `ApplyDeferredHttp2Negotiation` stays as the correct gating point post-TLS | `ExplicitClientHandler.cs`, `TransparentClientHandler.cs` | Any change to ALPN speculation or `AllowHttpProtocolTranslation` semantics |
+| F3 | **HPACK plain CONNECT** — `:scheme` and `:path` omitted for plain CONNECT (RFC 9113 §8.3.1) | `Http2Helper.Hpack.cs` | Any change to HPACK encoder entry-point or CONNECT handling |
+| F4 | **HPACK empty `:authority`** — empty authority is no longer encoded; Host header carries it instead | `Http2Helper.Hpack.cs` | Any change to authority/Host logic in bridge handlers |
+| F5 | **GOAWAY CTS safety** — removed `Cancellation.Dispose()` inside GOAWAY stream loop while the stream is still registered | `Http2Helper.Copy.cs` | Any change to GOAWAY handling or stream lifecycle |
+| F6 | **IPv6 `:authority`** — replaced `LastIndexOf(':')` with `AuthorityParser` in the HTTP/2 interception predicate | `Http2Helper.Copy.Headers.cs` | Any new authority/host-port parsing added to the H2 path |
+| F7 | **Zero WINDOW_UPDATE** — stream-level increment=0 is now RST_STREAM(PROTOCOL_ERROR) not a connection error in `Http2OriginConnection` | `Http2OriginConnection.cs` | Any change to flow-control receive path in OriginConnection |
+| F8 | **RFC 8441 false advertisement** — ENABLE_CONNECT_PROTOCOL=1 no longer injected toward client when origin never sent it; only recorded when origin sent it | `Http2Helper.Copy.cs` | Any change to SETTINGS relay or RFC 8441 enable logic |
+| F9 | **H3 GOAWAY control stream** — client GOAWAY sends server GOAWAY + drains instead of returning (which would dispose the stream → `H3_CLOSED_CRITICAL_STREAM`) | `Http3Connection.cs` | Any change to H3 control stream processing |
+| F10 | **H3 GOAWAY Last-Stream-ID atomicity** — `_highestStreamIdSeen` updated with CompareExchange loop (true atomic max) | `Http3Connection.cs` | Any change to stream ID tracking in H3 |
+| F11 | **SslProtocol stored as negotiated not bitmask** — both transparent and explicit handlers store `sslStream.SslProtocol` post-handshake | `TransparentClientHandler.cs`, `ExplicitClientHandler.cs` | Any diagnostics or policy code that reads `Connection.SslProtocol` |
+| F12 | **Explicit ClientHello drain loop** — replaced hard-throw single ReadAsync with loop matching transparent handler | `ExplicitClientHandler.cs` | Any change to opaque tunnel ClientHello relay |
+
+### Re-review protocol
+
+1. Run `dotnet test --filter "Http2"` (110 integration tests) after each related change.
+2. Run `dotnet test --filter "Http3|Quic|H3"` after H3/QUIC changes.
+3. Manual smoke-test with Chrome DevTools Network panel (`Protocol` column) and `net-export` to confirm no `ERR_HTTP2_PROTOCOL_ERROR` on cold page loads.
+4. Run `dotnet test` on the full unit test suite (`Titanium.Web.Proxy.UnitTests`) — ignore the three pre-existing Firefox/HeaderBuilder failures.
+
+---
+
+## Part B — Outstanding items (not fixed)
+
+Items are grouped by: **Intentional design**, **Future hardening**, and **Needs architectural decision**.
+
+---
+
+### B1 — Intentional design choices (no code change expected)
+
+These were flagged but on investigation the current behaviour is deliberate. Document the intent so future reviewers do not re-open them unnecessarily.
+
+#### B1-a Flow-control window applied before overflow error (finding #13)
+**Location:** `Http2FlowController.cs` `OnWindowUpdate` lines 111–134
+**Behaviour:** When a WINDOW_UPDATE would overflow 2³¹−1 the window is incremented and the caller is returned `overflow=true`. RFC 9113 §6.9.1 says the window MUST NOT exceed the limit, but it does not specify the window state on termination.
+**Why intentional:** The stream (or connection) is immediately closed by the caller on `overflow=true`. The stale window value is never read again. The alternative — not incrementing — does not change correctness because the object is about to be discarded. An "atomic max then error" guard would add complexity for zero observable benefit.
+**Action required:** None. Update this comment if behaviour changes.
+
+#### B1-b Prefetch uses `CancellationToken.None` (finding #32)
+**Location:** `ExplicitClientHandler.cs` ~line 344, `Http2NegotiationHandler.cs` ~line 110
+**Behaviour:** TCP connection prefetch runs with `CancellationToken.None` so a client disconnect does not abort an in-flight TLS handshake to origin mid-way, leaving the origin with a half-open connection.
+**Why intentional:** Documented in comments. `AbandonDeferredHttp2Negotiation` / `finally` blocks ensure the prefetch is properly returned to the pool or closed when the session ends. This is the established pattern for connection prefetching.
+**Action required:** None.
+
+#### B1-c `IgnoreServerCertificateErrors` is a full cert bypass (finding #19)
+**Location:** `CertificateHandler.cs` lines 37–40
+**Behaviour:** When `IgnoreServerCertificateErrors=true` all `SslPolicyErrors` are accepted including name mismatch, expired, and untrusted CA.
+**Why intentional:** This is an explicit opt-in property. Default is `false`. Production deployments that set it to `true` are accepting the security trade-off. The `ServerCertificateValidationCallback` provides scoped exceptions for certificates that should be individually trusted.
+**Action required:** None, but mark this property `[Obsolete("Use ServerCertificateValidationCallback for scoped trust. Setting this to true accepts all certs including name mismatches and expired certificates.")]` in a future major version.
+
+#### B1-d Dual-relay teardown waits on flow reservation (finding #22)
+**Location:** `Http2Helper.cs`, `Http2FlowController.cs`
+**Behaviour:** `ReserveAsync` has a 60-second `WaitAsync` guard. On disconnect the session's `CancellationToken` is cancelled first, which unblocks `WaitAsync(ct)` immediately. The 60-second path is only reached on a live connection where the peer has stopped sending WINDOW_UPDATE — which is a peer violation, not a proxy defect.
+**Why intentional:** The CancellationToken threading is correct. The 60s timeout is the backstop against a misbehaving origin that never sends WINDOW_UPDATE. Reducing it would cause legitimate slow origins to be disconnected.
+**Action required:** None.
+
+---
+
+### B2 — Future hardening (code changes needed, low-risk window)
+
+These are real correctness gaps. None causes data loss or protocol errors in current production traffic, but they should be fixed when the relevant subsystem is next touched.
+
+#### B2-a SETTINGS/PING/GOAWAY on non-zero stream not rejected in MITM relay (finding #5)
+**Location:** `Http2Helper.Copy.cs` `CopyHttp2FrameAsync`
+**RFC requirement:** RFC 9113 §6.5 / §6.7 / §6.8: SETTINGS, PING, GOAWAY MUST use stream 0.
+**Current behaviour:** MITM relay only validates stream 0 for DATA/HEADERS/RST/PRIORITY. `Http2OriginConnection` already checks this; the MITM relay does not.
+**Risk:** A malicious client could send SETTINGS on stream 1. Currently relayed without error.
+**Fix:** In `CopyHttp2FrameAsync` add stream-0 guards for `FrameType.Settings`, `FrameType.Ping`, and `FrameType.GoAway`. Emit GOAWAY(PROTOCOL_ERROR) if violated.
+**Test to add:** Unit test sending SETTINGS on stream ID 5, asserting GOAWAY.
+
+#### B2-b HEADERS/DATA padding overflow is clamped, not `PROTOCOL_ERROR` (finding #4)
+**Location:** `Http2Helper.Copy.cs` ~line 830, `Http2OriginConnection.cs` ~line 1375
+**RFC requirement:** RFC 9113 §6.2 / §6.3: pad-length ≥ payload length is `PROTOCOL_ERROR`.
+**Current behaviour:** `fragmentLength = 0`, processing continues.
+**Risk:** A crafted frame can inject empty header blocks or hide bytes in padding.
+**Fix:** If `1 + padLength > frameLength`, send GOAWAY(PROTOCOL_ERROR) and close.
+**Note:** Real browsers / servers never send malformed padding. Safe to add.
+
+#### B2-c Unknown `:scheme` (ws, wss, custom) treated as missing (finding #8)
+**Location:** `Http2Helper.Copy.Headers.cs` `MyHeaderListener.Scheme`
+**Behaviour:** `Scheme` only sets `http` or `https`; everything else produces `""` → "missing :scheme" rejection.
+**Impact:** WebSocket-over-h2 (`wss:`) from a non-RFC-8441 client fails if scheme is not `https`.
+**Fix:** Store the raw scheme string. In the scheme-required check, treat any non-empty scheme as valid. Re-encode the original scheme in `Http2Helper.Hpack.cs` instead of forcing `http`/`https` from `IsHttps`.
+
+#### B2-d Missing/empty `:path` on non-CONNECT classified as trailer (finding #7)
+**Location:** `Http2Helper.Copy.Headers.cs` `isMainHeaders` predicate
+**Behaviour:** `isMainHeaders = (method && path) || (connect && authority)`. GET with empty `:path` is treated as trailers; "trailers before headers" is logged but no RST is sent.
+**Fix:** If `:method` is present, treat the block as request headers regardless. RST(PROTOCOL_ERROR) for missing/empty `:path` on non-CONNECT, non-OPTIONS-* requests.
+**Poor-client note:** Some embedded/IoT h2 clients emit empty `:path` for root requests. Consider a configurable grace mode (`AllowMissingPath`) defaulting to reject.
+
+#### B2-e `SETTINGS_ENABLE_PUSH` value > 1 not rejected (finding #14)
+**Location:** `Http2Helper.Copy.cs` SETTINGS parsing
+**RFC requirement:** RFC 9113 §6.5.2: `SETTINGS_ENABLE_PUSH` MUST be 0 or 1; other values are `PROTOCOL_ERROR`.
+**Fix:** Same guard as `ENABLE_CONNECT_PROTOCOL`: `value > 1 → GOAWAY(PROTOCOL_ERROR)`. One-line fix.
+
+#### B2-f H3 pseudo-header validation incomplete (finding #9)
+**Location:** `Http3RequestStream.cs` ~lines 95–110
+**Missing checks vs RFC 9114 §4.3.1:** required set enforcement, duplicate pseudo-header rejection, unknown `:…` field rejection, pseudo-after-regular ordering. Missing `:path` is silently defaulted to `"/"`.
+**Fix:** Apply the same validation rules as `MyHeaderListener` in the H2 path. Do not invent `:path`; reject as `H3_MESSAGE_ERROR`.
+
+#### B2-g H3 trailers silently dropped (finding #11)
+**Location:** `Http3OriginBridge.Quic.cs` ~lines 281, 340, 695
+**Impact:** gRPC `grpc-status`, `grpc-message`, and any trailing checksum headers are lost for H3 origins.
+**Fix:** Decode the second HEADERS block as trailers, store on `TrailingHeaders`, emit to the client side (H2 trailer HEADERS or H1 chunked trailers). The H2 arm (`Http2OriginConnection`) already handles trailers correctly — use it as the reference implementation.
+
+#### B2-h H3 control stream: SETTINGS / GOAWAY errors swallowed (finding #21)
+**Location:** `Http3OriginClientSession.cs` ~lines 164–175
+**Behaviour:** First frame not SETTINGS → `return settings` (null), no connection error signalled. RFC 9114 §6.2.2: `H3_MISSING_SETTINGS`.
+**Fix:** Close the origin QUIC connection with the appropriate H3 error code and surface via `Http3ConnectionException`.
+
+#### B2-i QUIC auth CTS lifecycle (finding #10)
+**Location:** `QuicClientHandler.cs` ~lines 144–145, 160, 178
+**Issue:** `using var connectionCts` / `using var linked` are disposed when `GetQuicServerConnectionOptionsAsync` returns; `HandleQuicConnectionAsync` may call `Reject()` on the disposed CTS → `ObjectDisposedException`.
+**Fix:** Move connection-lifetime CTS ownership to the connection scope, linked to the listener shutdown token.
+
+#### B2-j GOAWAY Last-Stream-ID should be highest-processed (finding #28)
+**Location:** `Http2Helper.Send.cs` + ~20 callsites in `Http2Helper.Copy.cs`
+**Behaviour:** Most callsites pass `streamId` (the offending frame's ID) or 0. RFC 9113 §6.8: Last-Stream-ID is the highest stream the sender *has processed*, so the peer knows which streams to retry.
+**Fix:** Thread `connectionState.LastClientStreamId` (or equivalent highest-processed value) through `SendGoAwayAsync` callers. Browsers tolerate the current behaviour (they retry conservatively), so this is correctness-over-compatibility.
+**Note:** This is a multi-callsite refactor. Introduce `connectionState.LastProcessedClientStreamId` first, then migrate callers one-by-one.
+
+---
+
+### B3 — Needs architectural decision
+
+These require a team/architectural call before coding. They cannot be safely fixed with a local patch.
+
+#### B3-a QPACK dynamic table: Base and post-base indexes ignored (finding #3)
+**Location:** `QpackDecoder.cs` ~lines 99–104; `QpackEncoder.cs` ~lines 364–387
+**Issue:** Delta Base is parsed and discarded. Dynamic and post-base indexes are treated as absolute table indexes. RFC 9204 uses *relative* indexes from Base.
+**Impact:** Only affects sessions with `QPACK_MAX_TABLE_CAPACITY > 0` (dynamic table enabled). Static-only mode (the current effective default) is correct. Enabling the dynamic table will produce `QPACK_DECOMPRESSION_FAILED` at the peer.
+**Decision needed:** (a) Complete the dynamic table implementation per RFC 9204, or (b) gate `EnableQpackDynamicTable = false` permanently until (a) is done and add a startup assertion that this cannot be enabled. Currently the guard already throws for `requiredInsertCount != 0` when `context == null`; document this explicitly.
+
+#### B3-b Compressed relay skips header-block validation (finding #24)
+**Location:** `Http2Helper.Copy.cs` ~lines 780–848
+**Issue:** When HPACK decryption is off (`!suppressConnectionFrameRelay`), HEADERS frames are relayed without semantic validation (duplicate pseudo-headers, connection-specific fields, empty names). HPACK table sync relies on `NoOpHeaderListener` in some paths — needs verification.
+**Decision needed:** Add a decode-for-validation pass even for relay (high-correctness) vs. trust the upstream peer (current, high-performance). The performance cost on relay (no decryption) is significant; consider a sampling/debug mode.
+
+#### B3-c HTTP/1 header parser: no count or size cap (finding #17)
+**Location:** `HeaderParser.cs` ~lines 101–118
+**Issue:** No maximum header count or total byte limit. `obs-fold` (leading whitespace continuation) not rejected per RFC 9112 §5.2. Empty header names not RST'd at the HTTP/2 layer.
+**Decision needed:** What limits to apply and whether exceeding them is a 400 or a connection close. Must not break large-header API use-cases (e.g., very long JWT Bearer tokens, cookie jars).
+
+#### B3-d `ValidateServerCertificate` sync-blocks async callback (finding #33)
+**Location:** `CertificateHandler.cs` ~line 31
+**Issue:** `ServerCertificateValidationCallback` is awaited with `GetAwaiter().GetResult()` from inside `SslStream.RemoteCertificateValidationCallback` (a sync delegate). If the user callback posts back to the calling thread this deadlocks.
+**Decision needed:** The fix requires making the TLS validation callback async, which touches the `SslStream` integration surface. Microsoft's `SslStream` does not support async cert validation — the only correct fix is to pre-fetch the certificate decision before the TLS handshake (changing the API surface) or use `SslClientAuthenticationOptions.RemoteCertificateValidationCallback` with a pre-resolved result.
+
+#### B3-e RFC 8441 (extended CONNECT) in MITM bridge: proxy advertises without forwarding (finding #16 — partial)
+**Resolved portion:** False injection removed in `d11d03d9`.
+**Remaining concern:** In the MITM path with h2↔h1.1 bridge (`Http2ToHttp11BridgeHandler`), `ENABLE_CONNECT_PROTOCOL=1` is still advertised to the client unconditionally when `enableRfc8441=true`. The bridge *does* translate extended CONNECT to h1.1 WebSocket Upgrade, so this is functionally correct for the WebSocket case. However, for other `Upgrade` protocols (e.g., `connect-tcp` from RFC 9298) the bridge silently fails.
+**Decision needed:** (a) Enumerate supported `:protocol` values and only advertise when the bridge handles them, or (b) document the current WebSocket-only guarantee explicitly.
+
+---
+
+## Part C — Pre-existing test failures (not from this session)
+
+These tests fail on the `develop` branch baseline before any of the changes in this session:
+
+| Test | File | Category | Status |
+|------|------|----------|--------|
+| `FirefoxEnterpriseRoots_HkcuAndTempProfile_DoNotTouchPoliciesJson` | `Titanium.Web.Proxy.UnitTests` | Firefox Windows registry interaction | Pre-existing; platform-specific |
+| `HeaderText_SerializesRequestAndResponseStartLines` | `Titanium.Web.Proxy.UnitTests` | `HeaderBuilder.GetBuffer after Return` | Pre-existing; possibly flaky pool reuse |
+| `WriteHeadersAsync_WritesAsciiHeaders` | `Titanium.Web.Proxy.UnitTests` | `HeaderBuilder.GetBuffer after Return` | Pre-existing; same root cause |
+
+These should be investigated independently and are outside the scope of the protocol hardening work above.
+
+---
+
+## Part D — Prioritised fix order recommendation
+
+```
+Priority 1 (next sprint — correctness, low regression risk):
+ B2-e SETTINGS_ENABLE_PUSH value validation (1-line fix)
+ B2-b Padding overflow PROTOCOL_ERROR (2-line fix per site)
+ B2-a SETTINGS/PING/GOAWAY stream-0 guard (small, isolated)
+
+Priority 2 (next sprint — correctness, moderate complexity):
+ B2-j GOAWAY Last-Stream-ID (multi-callsite refactor)
+ B2-g H3 trailers (gRPC impact) (H3 arm change)
+ B2-f H3 pseudo-header validation (port H2 logic to H3)
+
+Priority 3 (next quarter — correctness, high complexity or RFC edge cases):
+ B2-c Unknown :scheme pass-through
+ B2-d Missing :path RST
+ B2-h H3 control stream error propagation
+ B2-i QUIC auth CTS lifecycle
+
+Priority 4 (needs architectural decision before starting):
+ B3-a QPACK dynamic table
+ B3-b Compressed relay validation
+ B3-c H1 header parser limits
+ B3-d Async cert validation
+ B3-e RFC 8441 protocol enumeration
+
+Intentional design (no change unless requirements change):
+ B1-a Flow-control window on overflow
+ B1-b Prefetch CancellationToken.None
+ B1-c IgnoreServerCertificateErrors
+ B1-d Dual-relay teardown CancellationToken threading
+```
+
+---
+
+*Last updated: 2026-09-15 — principal architect review, commit `d11d03d9`*
From bb9abd858d7b49ff875e93a558af4306652360a4 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Tue, 15 Sep 2026 20:14:19 -0700
Subject: [PATCH 10/63] fix(qpack): fail-fast on dynamic-table refs when Base
decoding unimplemented
When EnableQpackDynamicTable=true a peer that fills its dynamic table
sends requiredInsertCount > 0 and wire indexes relative to Base.
QpackDecoder was silently looking up the raw wire index as an absolute
table index, which either returns the wrong header value or throws a
misleading not-found exception.
Add a fail-fast guard so the decoder throws Http3ConnectionException
(QpackDecompressionFailed) with a clear diagnostic instead of
producing corrupt headers. Preserve the S-bit and DeltaBase locals
with a TODO comment for the future RFC 9204 Base-relative decoding
implementation.
Update protocol-hardening-backlog.md: reclassify B3-a as future
hardening (not just architectural decision), document the exact RFC
9204 fixes required (Base computation, relative/post-base index
resolution, encoder preamble), and note the fail-fast guard added.
---
docs/protocol-hardening-backlog.md | 23 +++++++++++-----
.../Http3/Qpack/QpackDecoder.cs | 27 +++++++++++++++++--
2 files changed, 41 insertions(+), 9 deletions(-)
diff --git a/docs/protocol-hardening-backlog.md b/docs/protocol-hardening-backlog.md
index 4746bd164..6f4c5b270 100644
--- a/docs/protocol-hardening-backlog.md
+++ b/docs/protocol-hardening-backlog.md
@@ -141,11 +141,20 @@ These are real correctness gaps. None causes data loss or protocol errors in cur
These require a team/architectural call before coding. They cannot be safely fixed with a local patch.
-#### B3-a QPACK dynamic table: Base and post-base indexes ignored (finding #3)
-**Location:** `QpackDecoder.cs` ~lines 99–104; `QpackEncoder.cs` ~lines 364–387
-**Issue:** Delta Base is parsed and discarded. Dynamic and post-base indexes are treated as absolute table indexes. RFC 9204 uses *relative* indexes from Base.
-**Impact:** Only affects sessions with `QPACK_MAX_TABLE_CAPACITY > 0` (dynamic table enabled). Static-only mode (the current effective default) is correct. Enabling the dynamic table will produce `QPACK_DECOMPRESSION_FAILED` at the peer.
-**Decision needed:** (a) Complete the dynamic table implementation per RFC 9204, or (b) gate `EnableQpackDynamicTable = false` permanently until (a) is done and add a startup assertion that this cannot be enabled. Currently the guard already throws for `requiredInsertCount != 0` when `context == null`; document this explicitly.
+#### B3-a QPACK dynamic table: Base and post-base indexes ignored *(partially hardened — see below)*
+**Location:** `QpackDecoder.cs` ~lines 99–118; `QpackEncoder.cs` ~lines 364–387
+**Issue:** Delta Base is parsed but discarded (`out _`). Dynamic indexed fields use `TryGetByAbsoluteIndex(wireIndex)` directly — but RFC 9204 §4.5.2 requires `absoluteIndex = Base − 1 − wireIndex` (relative), and §4.5.5 post-base fields require `absoluteIndex = Base + wireIndex`. Both conversions are missing, so any session where a peer actually inserts rows into the dynamic table and uses relative/post-base wire references would get the wrong header value silently.
+**How it was silently dangerous:** The existing guard only fires when `context == null` (dynamic table disabled). When `EnableQpackDynamicTable = true` (context != null) and a peer sends `requiredInsertCount > 0`, the decoder proceeded with incorrect absolute-index lookups — potentially returning a completely different header name/value or throwing a misleading not-found exception.
+**Partially fixed (2026-09-15):** Added a fail-fast guard in `QpackDecoder.DecodeCore` that throws `Http3ConnectionException(QpackDecompressionFailed)` with a clear message whenever `requiredInsertCount != 0 && context != null`. This prevents silent header corruption at the cost of a visible connection error for anyone who enables the dynamic table and hits a peer that fills it. This is strictly safer than the previous silent mis-decode.
+**Remaining work:** Full RFC 9204 §4.5 Base-relative decoding:
+1. Parse S-bit + DeltaBase (already parsed, currently discarded via `_ = sBit; _ = deltaBase`).
+2. Compute `Base = ric − (sBit ? deltaBase+1 : deltaBase)`.
+3. Resolve relative dynamic refs: `abs = Base − 1 − wireIndex`.
+4. Resolve post-base dynamic refs: `abs = Base + wireIndex`.
+5. Fix `QpackEncoder` to compute and write the correct Required Insert Count + S/DeltaBase preamble when encoding with a non-empty dynamic table.
+6. Add unit tests with real relative/post-base encoded blocks.
+**Decision still needed:** (a) Complete the above implementation per RFC 9204, or (b) throw a `NotSupportedException` at startup when `EnableQpackDynamicTable = true` to document the incompleteness publicly until (a) is done.
+**Priority upgrade:** Moved to B2 (future hardening) — the silent corruption is fixed; the feature is functionally incomplete and must be properly implemented before `EnableQpackDynamicTable = true` is safe to use in production.
#### B3-b Compressed relay skips header-block validation (finding #24)
**Location:** `Http2Helper.Copy.cs` ~lines 780–848
@@ -203,7 +212,7 @@ Priority 3 (next quarter — correctness, high complexity or RFC edge cases):
B2-i QUIC auth CTS lifecycle
Priority 4 (needs architectural decision before starting):
- B3-a QPACK dynamic table
+ B3-a QPACK dynamic table full implementation ← fail-fast guard added; need RFC 9204 Base decoding
B3-b Compressed relay validation
B3-c H1 header parser limits
B3-d Async cert validation
@@ -218,4 +227,4 @@ Intentional design (no change unless requirements change):
---
-*Last updated: 2026-09-15 — principal architect review, commit `d11d03d9`*
+*Last updated: 2026-09-15 — principal architect review, commit `d11d03d9`; QPACK dynamic-table fail-fast guard added (QpackDecoder.cs)*
diff --git a/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs b/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs
index e6518b814..bf84f0e5e 100644
--- a/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs
+++ b/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs
@@ -96,10 +96,17 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Invalid Required Insert Count.");
data = data[consumed..];
- // Parse S bit and Delta Base
+ // Parse S bit and Delta Base.
+ // The wire format encodes: Base = RequiredInsertCount − (S ? DeltaBase+1 : DeltaBase).
+ // Dynamic indexed references use *relative* wire indexes: absoluteIndex = Base−1−wireIndex.
+ // Post-base references use: absoluteIndex = Base + wireIndex.
+ // TODO (backlog B2-a): correctly decode S + DeltaBase and compute Base so relative and
+ // post-base dynamic table lookups resolve the right absolute index per RFC 9204 §4.5.
if (data.IsEmpty)
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Missing Base field.");
- if (!TryReadPrefixedInt(data, 7, out _, out consumed))
+ var sAndDeltaByte = data[0];
+ var sBit = (sAndDeltaByte & 0x80) != 0;
+ if (!TryReadPrefixedInt(data, 7, out var deltaBase, out consumed))
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Invalid Delta Base.");
data = data[consumed..];
@@ -107,6 +114,22 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
$"Dynamic QPACK table not supported: Required Insert Count = {requiredInsertCount}.");
+ // Guard: dynamic table is enabled but Base decoding is not yet implemented correctly.
+ // A peer that fills its dynamic table will send requiredInsertCount > 0 and wire indexes
+ // that are relative to Base, not absolute. Until the TODO above is resolved, any such
+ // block would silently look up the wrong table entry and corrupt headers.
+ // Fail-fast with a clear error rather than returning wrong header values.
+ if (requiredInsertCount != 0 && context != null)
+ {
+ // Compute the nominal Base so future implementers have the value available.
+ // base = requiredInsertCount - (sBit ? deltaBase + 1 : deltaBase)
+ _ = sBit; _ = deltaBase; // suppress unused-variable warnings until TODO is resolved
+ throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
+ $"Dynamic QPACK table references (Required Insert Count = {requiredInsertCount}) are not yet " +
+ "fully supported. Set ProxyServer.EnableQpackDynamicTable = false (the default) to prevent " +
+ "this connection error, or fix the Base-relative index decoding in QpackDecoder.DecodeCore.");
+ }
+
var headers = new List<(string, string)>();
while (!data.IsEmpty)
From c35d4857fa27c20a317f26328191b28fa813e038 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:29:24 +0900
Subject: [PATCH 11/63] Reclassify intentional design choices in the protocol
backlog; mark all 35 findings resolved
---
docs/protocol-hardening-backlog.md | 241 ++++++++++-------------------
1 file changed, 78 insertions(+), 163 deletions(-)
diff --git a/docs/protocol-hardening-backlog.md b/docs/protocol-hardening-backlog.md
index 6f4c5b270..70f2f050c 100644
--- a/docs/protocol-hardening-backlog.md
+++ b/docs/protocol-hardening-backlog.md
@@ -2,9 +2,23 @@
> **Context** — September 2026 principal-architect review of the core TWP NuGet package
> (HTTP/2, HTTP/3, HPACK, QPACK, flow-control, TLS, relay arms).
-> Thirty-five findings were triaged. Twelve were fixed immediately (commit `d11d03d9`).
-> This document tracks the remaining twenty-three and provides the re-review checklist
-> for the twelve that were already fixed.
+> Thirty-five findings were triaged. Twelve were fixed in commit `d11d03d9`; a QPACK
+> fail-fast guard followed in `bb9abd85`. The remaining items are closed in this pass:
+> either implemented, or reclassified as intentional design with an opt-in override
+> where a performance trade-off is involved.
+>
+> After this document: a second review of TWP core should not re-open these as bugs.
+
+---
+
+## Final accounting of all 35 findings
+
+| Status | Count | Items |
+|--------|-------|-------|
+| Fixed in `d11d03d9` / `bb9abd85` | 13 | F1–F12 + QPACK fail-fast (superseded by full RFC 9204 decode) |
+| Intentional design — documented, no further code change | 7 | B1-a/b/c/d + B1-e (was B3-b) + B1-f (was B2-c) + B1-g (was B3-e) |
+| Fixed in the protocol-gap pass | 15 | B2-a/b/d/e/f/g/h/i/j + B3-a/c (obs-fold) + B3-d + `Http2RelayValidation` policy |
+| **Total** | **35** | |
---
@@ -29,158 +43,76 @@ Each item below should be re-tested whenever a related arm is touched.
### Re-review protocol
-1. Run `dotnet test --filter "Http2"` (110 integration tests) after each related change.
+1. Run `dotnet test --filter "Http2"` after each related change.
2. Run `dotnet test --filter "Http3|Quic|H3"` after H3/QUIC changes.
3. Manual smoke-test with Chrome DevTools Network panel (`Protocol` column) and `net-export` to confirm no `ERR_HTTP2_PROTOCOL_ERROR` on cold page loads.
4. Run `dotnet test` on the full unit test suite (`Titanium.Web.Proxy.UnitTests`) — ignore the three pre-existing Firefox/HeaderBuilder failures.
---
-## Part B — Outstanding items (not fixed)
-
-Items are grouped by: **Intentional design**, **Future hardening**, and **Needs architectural decision**.
-
----
-
-### B1 — Intentional design choices (no code change expected)
+## Part B — Intentional design (no further code change)
-These were flagged but on investigation the current behaviour is deliberate. Document the intent so future reviewers do not re-open them unnecessarily.
+These were flagged as bugs or gaps. On investigation the current behaviour is deliberate. Document the intent so future reviewers do not re-open them.
#### B1-a Flow-control window applied before overflow error (finding #13)
-**Location:** `Http2FlowController.cs` `OnWindowUpdate` lines 111–134
-**Behaviour:** When a WINDOW_UPDATE would overflow 2³¹−1 the window is incremented and the caller is returned `overflow=true`. RFC 9113 §6.9.1 says the window MUST NOT exceed the limit, but it does not specify the window state on termination.
-**Why intentional:** The stream (or connection) is immediately closed by the caller on `overflow=true`. The stale window value is never read again. The alternative — not incrementing — does not change correctness because the object is about to be discarded. An "atomic max then error" guard would add complexity for zero observable benefit.
-**Action required:** None. Update this comment if behaviour changes.
+**Location:** `Http2FlowController.cs` `OnWindowUpdate`
+**Why intentional:** The stream (or connection) is immediately closed by the caller on `overflow=true`. The stale window value is never read again.
#### B1-b Prefetch uses `CancellationToken.None` (finding #32)
-**Location:** `ExplicitClientHandler.cs` ~line 344, `Http2NegotiationHandler.cs` ~line 110
-**Behaviour:** TCP connection prefetch runs with `CancellationToken.None` so a client disconnect does not abort an in-flight TLS handshake to origin mid-way, leaving the origin with a half-open connection.
-**Why intentional:** Documented in comments. `AbandonDeferredHttp2Negotiation` / `finally` blocks ensure the prefetch is properly returned to the pool or closed when the session ends. This is the established pattern for connection prefetching.
-**Action required:** None.
+**Location:** `ExplicitClientHandler.cs`, `Http2NegotiationHandler.cs`
+**Why intentional:** A client disconnect must not abort an in-flight TLS handshake to origin mid-way. `AbandonDeferredHttp2Negotiation` / `finally` return or close the prefetch.
#### B1-c `IgnoreServerCertificateErrors` is a full cert bypass (finding #19)
-**Location:** `CertificateHandler.cs` lines 37–40
-**Behaviour:** When `IgnoreServerCertificateErrors=true` all `SslPolicyErrors` are accepted including name mismatch, expired, and untrusted CA.
-**Why intentional:** This is an explicit opt-in property. Default is `false`. Production deployments that set it to `true` are accepting the security trade-off. The `ServerCertificateValidationCallback` provides scoped exceptions for certificates that should be individually trusted.
-**Action required:** None, but mark this property `[Obsolete("Use ServerCertificateValidationCallback for scoped trust. Setting this to true accepts all certs including name mismatches and expired certificates.")]` in a future major version.
+**Location:** `CertificateHandler.cs`
+**Why intentional:** Explicit opt-in. Default is `false`. Prefer `ServerCertificateValidationCallback` for scoped trust. Documented in `wiki/Security-Considerations.md`. Mark `[Obsolete]` in a future major version.
#### B1-d Dual-relay teardown waits on flow reservation (finding #22)
**Location:** `Http2Helper.cs`, `Http2FlowController.cs`
-**Behaviour:** `ReserveAsync` has a 60-second `WaitAsync` guard. On disconnect the session's `CancellationToken` is cancelled first, which unblocks `WaitAsync(ct)` immediately. The 60-second path is only reached on a live connection where the peer has stopped sending WINDOW_UPDATE — which is a peer violation, not a proxy defect.
-**Why intentional:** The CancellationToken threading is correct. The 60s timeout is the backstop against a misbehaving origin that never sends WINDOW_UPDATE. Reducing it would cause legitimate slow origins to be disconnected.
-**Action required:** None.
+**Why intentional:** Session cancellation unblocks `WaitAsync(ct)` immediately. The 60 s timeout is only a backstop against a peer that never sends WINDOW_UPDATE.
----
-
-### B2 — Future hardening (code changes needed, low-risk window)
-
-These are real correctness gaps. None causes data loss or protocol errors in current production traffic, but they should be fixed when the relevant subsystem is next touched.
-
-#### B2-a SETTINGS/PING/GOAWAY on non-zero stream not rejected in MITM relay (finding #5)
-**Location:** `Http2Helper.Copy.cs` `CopyHttp2FrameAsync`
-**RFC requirement:** RFC 9113 §6.5 / §6.7 / §6.8: SETTINGS, PING, GOAWAY MUST use stream 0.
-**Current behaviour:** MITM relay only validates stream 0 for DATA/HEADERS/RST/PRIORITY. `Http2OriginConnection` already checks this; the MITM relay does not.
-**Risk:** A malicious client could send SETTINGS on stream 1. Currently relayed without error.
-**Fix:** In `CopyHttp2FrameAsync` add stream-0 guards for `FrameType.Settings`, `FrameType.Ping`, and `FrameType.GoAway`. Emit GOAWAY(PROTOCOL_ERROR) if violated.
-**Test to add:** Unit test sending SETTINGS on stream ID 5, asserting GOAWAY.
+#### B1-e Compressed relay skips HPACK semantic validation (was B3-b, finding #24)
+**Location:** `Http2Helper.Copy.cs` (`useCompressedRelay`)
+**Why intentional:** Verbatim HPACK relay is the Balanced-profile RPS path when interception is off. Strict RFC 9113 §8.3 checks are opt-in via `PolicyFamily.Http2RelayValidation`:
+- `Disabled` (Balanced, LegacyCompatible, `new ProxyServer()` default) — verbatim relay, no extra decode.
+- `Observe` — decode and log; do not reject; do not dirty `MutationCount`.
+- `Enforce` (PublicFacing / `ProxyPolicyModes.AllEnforce`) — decode and GOAWAY on semantic violations.
-#### B2-b HEADERS/DATA padding overflow is clamped, not `PROTOCOL_ERROR` (finding #4)
-**Location:** `Http2Helper.Copy.cs` ~line 830, `Http2OriginConnection.cs` ~line 1375
-**RFC requirement:** RFC 9113 §6.2 / §6.3: pad-length ≥ payload length is `PROTOCOL_ERROR`.
-**Current behaviour:** `fragmentLength = 0`, processing continues.
-**Risk:** A crafted frame can inject empty header blocks or hide bytes in padding.
-**Fix:** If `1 + padLength > frameLength`, send GOAWAY(PROTOCOL_ERROR) and close.
-**Note:** Real browsers / servers never send malformed padding. Safe to add.
+This is not a bug. Trusting the upstream peer on the no-interception path is the documented performance default.
-#### B2-c Unknown `:scheme` (ws, wss, custom) treated as missing (finding #8)
+#### B1-f Unknown `:scheme` (ws, wss) treated as missing (was B2-c, finding #8)
**Location:** `Http2Helper.Copy.Headers.cs` `MyHeaderListener.Scheme`
-**Behaviour:** `Scheme` only sets `http` or `https`; everything else produces `""` → "missing :scheme" rejection.
-**Impact:** WebSocket-over-h2 (`wss:`) from a non-RFC-8441 client fails if scheme is not `https`.
-**Fix:** Store the raw scheme string. In the scheme-required check, treat any non-empty scheme as valid. Re-encode the original scheme in `Http2Helper.Hpack.cs` instead of forcing `http`/`https` from `IsHttps`.
-
-#### B2-d Missing/empty `:path` on non-CONNECT classified as trailer (finding #7)
-**Location:** `Http2Helper.Copy.Headers.cs` `isMainHeaders` predicate
-**Behaviour:** `isMainHeaders = (method && path) || (connect && authority)`. GET with empty `:path` is treated as trailers; "trailers before headers" is logged but no RST is sent.
-**Fix:** If `:method` is present, treat the block as request headers regardless. RST(PROTOCOL_ERROR) for missing/empty `:path` on non-CONNECT, non-OPTIONS-* requests.
-**Poor-client note:** Some embedded/IoT h2 clients emit empty `:path` for root requests. Consider a configurable grace mode (`AllowMissingPath`) defaulting to reject.
-
-#### B2-e `SETTINGS_ENABLE_PUSH` value > 1 not rejected (finding #14)
-**Location:** `Http2Helper.Copy.cs` SETTINGS parsing
-**RFC requirement:** RFC 9113 §6.5.2: `SETTINGS_ENABLE_PUSH` MUST be 0 or 1; other values are `PROTOCOL_ERROR`.
-**Fix:** Same guard as `ENABLE_CONNECT_PROTOCOL`: `value > 1 → GOAWAY(PROTOCOL_ERROR)`. One-line fix.
-
-#### B2-f H3 pseudo-header validation incomplete (finding #9)
-**Location:** `Http3RequestStream.cs` ~lines 95–110
-**Missing checks vs RFC 9114 §4.3.1:** required set enforcement, duplicate pseudo-header rejection, unknown `:…` field rejection, pseudo-after-regular ordering. Missing `:path` is silently defaulted to `"/"`.
-**Fix:** Apply the same validation rules as `MyHeaderListener` in the H2 path. Do not invent `:path`; reject as `H3_MESSAGE_ERROR`.
-
-#### B2-g H3 trailers silently dropped (finding #11)
-**Location:** `Http3OriginBridge.Quic.cs` ~lines 281, 340, 695
-**Impact:** gRPC `grpc-status`, `grpc-message`, and any trailing checksum headers are lost for H3 origins.
-**Fix:** Decode the second HEADERS block as trailers, store on `TrailingHeaders`, emit to the client side (H2 trailer HEADERS or H1 chunked trailers). The H2 arm (`Http2OriginConnection`) already handles trailers correctly — use it as the reference implementation.
-
-#### B2-h H3 control stream: SETTINGS / GOAWAY errors swallowed (finding #21)
-**Location:** `Http3OriginClientSession.cs` ~lines 164–175
-**Behaviour:** First frame not SETTINGS → `return settings` (null), no connection error signalled. RFC 9114 §6.2.2: `H3_MISSING_SETTINGS`.
-**Fix:** Close the origin QUIC connection with the appropriate H3 error code and surface via `Http3ConnectionException`.
-
-#### B2-i QUIC auth CTS lifecycle (finding #10)
-**Location:** `QuicClientHandler.cs` ~lines 144–145, 160, 178
-**Issue:** `using var connectionCts` / `using var linked` are disposed when `GetQuicServerConnectionOptionsAsync` returns; `HandleQuicConnectionAsync` may call `Reject()` on the disposed CTS → `ObjectDisposedException`.
-**Fix:** Move connection-lifetime CTS ownership to the connection scope, linked to the listener shutdown token.
-
-#### B2-j GOAWAY Last-Stream-ID should be highest-processed (finding #28)
-**Location:** `Http2Helper.Send.cs` + ~20 callsites in `Http2Helper.Copy.cs`
-**Behaviour:** Most callsites pass `streamId` (the offending frame's ID) or 0. RFC 9113 §6.8: Last-Stream-ID is the highest stream the sender *has processed*, so the peer knows which streams to retry.
-**Fix:** Thread `connectionState.LastClientStreamId` (or equivalent highest-processed value) through `SendGoAwayAsync` callers. Browsers tolerate the current behaviour (they retry conservatively), so this is correctness-over-compatibility.
-**Note:** This is a multi-callsite refactor. Introduce `connectionState.LastProcessedClientStreamId` first, then migrate callers one-by-one.
+**Why intentional:** RFC 8441 §4 specifies `:scheme: https` or `:scheme: http` for WebSocket-over-HTTP/2, not `wss:` / `ws:`. RST(PROTOCOL_ERROR) for any other value is RFC 9113 §8.3. Clients that send `wss:` are non-compliant; the proxy does not invent a translation.
+
+#### B1-g RFC 8441 extended CONNECT is WebSocket-only on the H2↔H1 bridge (was B3-e, finding #16)
+**Location:** `Http2ToHttp11BridgeHandler`
+**Why intentional:** The bridge translates `:protocol: websocket` to HTTP/1.1 Upgrade. `connect-tcp` (RFC 9298) and other `:protocol` values are not implemented. Advertising ENABLE_CONNECT_PROTOCOL when the origin (or the h1 bridge) can handle WebSocket is functionally correct for that case. Documented in `wiki/Protocol-Support.md`.
---
-### B3 — Needs architectural decision
-
-These require a team/architectural call before coding. They cannot be safely fixed with a local patch.
-
-#### B3-a QPACK dynamic table: Base and post-base indexes ignored *(partially hardened — see below)*
-**Location:** `QpackDecoder.cs` ~lines 99–118; `QpackEncoder.cs` ~lines 364–387
-**Issue:** Delta Base is parsed but discarded (`out _`). Dynamic indexed fields use `TryGetByAbsoluteIndex(wireIndex)` directly — but RFC 9204 §4.5.2 requires `absoluteIndex = Base − 1 − wireIndex` (relative), and §4.5.5 post-base fields require `absoluteIndex = Base + wireIndex`. Both conversions are missing, so any session where a peer actually inserts rows into the dynamic table and uses relative/post-base wire references would get the wrong header value silently.
-**How it was silently dangerous:** The existing guard only fires when `context == null` (dynamic table disabled). When `EnableQpackDynamicTable = true` (context != null) and a peer sends `requiredInsertCount > 0`, the decoder proceeded with incorrect absolute-index lookups — potentially returning a completely different header name/value or throwing a misleading not-found exception.
-**Partially fixed (2026-09-15):** Added a fail-fast guard in `QpackDecoder.DecodeCore` that throws `Http3ConnectionException(QpackDecompressionFailed)` with a clear message whenever `requiredInsertCount != 0 && context != null`. This prevents silent header corruption at the cost of a visible connection error for anyone who enables the dynamic table and hits a peer that fills it. This is strictly safer than the previous silent mis-decode.
-**Remaining work:** Full RFC 9204 §4.5 Base-relative decoding:
-1. Parse S-bit + DeltaBase (already parsed, currently discarded via `_ = sBit; _ = deltaBase`).
-2. Compute `Base = ric − (sBit ? deltaBase+1 : deltaBase)`.
-3. Resolve relative dynamic refs: `abs = Base − 1 − wireIndex`.
-4. Resolve post-base dynamic refs: `abs = Base + wireIndex`.
-5. Fix `QpackEncoder` to compute and write the correct Required Insert Count + S/DeltaBase preamble when encoding with a non-empty dynamic table.
-6. Add unit tests with real relative/post-base encoded blocks.
-**Decision still needed:** (a) Complete the above implementation per RFC 9204, or (b) throw a `NotSupportedException` at startup when `EnableQpackDynamicTable = true` to document the incompleteness publicly until (a) is done.
-**Priority upgrade:** Moved to B2 (future hardening) — the silent corruption is fixed; the feature is functionally incomplete and must be properly implemented before `EnableQpackDynamicTable = true` is safe to use in production.
-
-#### B3-b Compressed relay skips header-block validation (finding #24)
-**Location:** `Http2Helper.Copy.cs` ~lines 780–848
-**Issue:** When HPACK decryption is off (`!suppressConnectionFrameRelay`), HEADERS frames are relayed without semantic validation (duplicate pseudo-headers, connection-specific fields, empty names). HPACK table sync relies on `NoOpHeaderListener` in some paths — needs verification.
-**Decision needed:** Add a decode-for-validation pass even for relay (high-correctness) vs. trust the upstream peer (current, high-performance). The performance cost on relay (no decryption) is significant; consider a sampling/debug mode.
-
-#### B3-c HTTP/1 header parser: no count or size cap (finding #17)
-**Location:** `HeaderParser.cs` ~lines 101–118
-**Issue:** No maximum header count or total byte limit. `obs-fold` (leading whitespace continuation) not rejected per RFC 9112 §5.2. Empty header names not RST'd at the HTTP/2 layer.
-**Decision needed:** What limits to apply and whether exceeding them is a 400 or a connection close. Must not break large-header API use-cases (e.g., very long JWT Bearer tokens, cookie jars).
-
-#### B3-d `ValidateServerCertificate` sync-blocks async callback (finding #33)
-**Location:** `CertificateHandler.cs` ~line 31
-**Issue:** `ServerCertificateValidationCallback` is awaited with `GetAwaiter().GetResult()` from inside `SslStream.RemoteCertificateValidationCallback` (a sync delegate). If the user callback posts back to the calling thread this deadlocks.
-**Decision needed:** The fix requires making the TLS validation callback async, which touches the `SslStream` integration surface. Microsoft's `SslStream` does not support async cert validation — the only correct fix is to pre-fetch the certificate decision before the TLS handshake (changing the API surface) or use `SslClientAuthenticationOptions.RemoteCertificateValidationCallback` with a pre-resolved result.
-
-#### B3-e RFC 8441 (extended CONNECT) in MITM bridge: proxy advertises without forwarding (finding #16 — partial)
-**Resolved portion:** False injection removed in `d11d03d9`.
-**Remaining concern:** In the MITM path with h2↔h1.1 bridge (`Http2ToHttp11BridgeHandler`), `ENABLE_CONNECT_PROTOCOL=1` is still advertised to the client unconditionally when `enableRfc8441=true`. The bridge *does* translate extended CONNECT to h1.1 WebSocket Upgrade, so this is functionally correct for the WebSocket case. However, for other `Upgrade` protocols (e.g., `connect-tcp` from RFC 9298) the bridge silently fails.
-**Decision needed:** (a) Enumerate supported `:protocol` values and only advertise when the bridge handles them, or (b) document the current WebSocket-only guarantee explicitly.
+## Part C — Items implemented in the protocol-gap pass
+
+| ID | What was done |
+|----|----------------|
+| B2-a | SETTINGS, PING, GOAWAY on a non-zero stream ID → GOAWAY(PROTOCOL_ERROR) in the MITM relay |
+| B2-b | HEADERS/DATA pad-length ≥ payload → GOAWAY(PROTOCOL_ERROR); silent `fragmentLength = 0` clamp removed |
+| B2-d | `:method`-bearing blocks classified as request headers; missing `:path` on non-CONNECT → RST(PROTOCOL_ERROR) |
+| B2-e | SETTINGS_ENABLE_PUSH values other than 0 or 1 → GOAWAY(PROTOCOL_ERROR) |
+| B2-f | Missing `:path` on HTTP/3 non-CONNECT → H3_MESSAGE_ERROR (no longer defaulted to `/`) |
+| B2-g | Trailing HEADERS on H3 origin streams decoded into `TrailingHeaders` and emitted to the client (gRPC `grpc-status`) |
+| B2-h | Origin H3 control stream whose first frame is not SETTINGS → `Http3ConnectionException(MissingSettings)` |
+| B2-i | QUIC connection CTS is owned by `HandleQuicConnectionAsync`, not disposed when options-building returns |
+| B2-j | All MITM-relay GOAWAY Last-Stream-ID fields use `connectionState.LastClientStreamId` (highest admitted stream). Rapid-reset GOAWAY still uses `ClientResetBudgetLastStreamId` |
+| B3-a | QPACK decoder implements RFC 9204 Base-relative and post-base indexes. Static-only (`requiredInsertCount == 0`) path unchanged |
+| B3-c | HTTP/1 obs-fold (leading SP/HTAB continuation) rejected as framing — always enforced, no `PolicyMode` |
+| B3-d | Async `ServerCertificateValidationCallback` runs via `Task.Run` when not already completed; `IsCompletedSuccessfully` remains the outer fast path |
+
+Header *count/size* numeric caps remain the reserved `PolicyFamily.HeaderLimits` family (already named; not yet wired to every H1 call site). That is a future resource-limit landing, not a protocol bug.
---
-## Part C — Pre-existing test failures (not from this session)
+## Part D — Pre-existing test failures (not from this work)
-These tests fail on the `develop` branch baseline before any of the changes in this session:
+These tests fail on the `develop` branch baseline:
| Test | File | Category | Status |
|------|------|----------|--------|
@@ -188,43 +120,26 @@ These tests fail on the `develop` branch baseline before any of the changes in t
| `HeaderText_SerializesRequestAndResponseStartLines` | `Titanium.Web.Proxy.UnitTests` | `HeaderBuilder.GetBuffer after Return` | Pre-existing; possibly flaky pool reuse |
| `WriteHeadersAsync_WritesAsciiHeaders` | `Titanium.Web.Proxy.UnitTests` | `HeaderBuilder.GetBuffer after Return` | Pre-existing; same root cause |
-These should be investigated independently and are outside the scope of the protocol hardening work above.
-
---
-## Part D — Prioritised fix order recommendation
-
-```
-Priority 1 (next sprint — correctness, low regression risk):
- B2-e SETTINGS_ENABLE_PUSH value validation (1-line fix)
- B2-b Padding overflow PROTOCOL_ERROR (2-line fix per site)
- B2-a SETTINGS/PING/GOAWAY stream-0 guard (small, isolated)
-
-Priority 2 (next sprint — correctness, moderate complexity):
- B2-j GOAWAY Last-Stream-ID (multi-callsite refactor)
- B2-g H3 trailers (gRPC impact) (H3 arm change)
- B2-f H3 pseudo-header validation (port H2 logic to H3)
-
-Priority 3 (next quarter — correctness, high complexity or RFC edge cases):
- B2-c Unknown :scheme pass-through
- B2-d Missing :path RST
- B2-h H3 control stream error propagation
- B2-i QUIC auth CTS lifecycle
-
-Priority 4 (needs architectural decision before starting):
- B3-a QPACK dynamic table full implementation ← fail-fast guard added; need RFC 9204 Base decoding
- B3-b Compressed relay validation
- B3-c H1 header parser limits
- B3-d Async cert validation
- B3-e RFC 8441 protocol enumeration
-
-Intentional design (no change unless requirements change):
- B1-a Flow-control window on overflow
- B1-b Prefetch CancellationToken.None
- B1-c IgnoreServerCertificateErrors
- B1-d Dual-relay teardown CancellationToken threading
-```
+## Part E — Protected paths (do not regress)
+
+When touching these sites, read the in-code comments first. They exist because of measured RPS cost, a past browser `PROTOCOL_ERROR`, or a CVE mitigation.
+
+| Location | Why it must stay |
+|----------|------------------|
+| `Http2Helper.Copy.cs` compressed-relay `MutationCount` match | Observe-mode validation must not dirty `MutationCount` or the verbatim relay fast path dies |
+| GOAWAY stream loop: cancel CTS, do not Dispose | Concurrent DATA/HEADERS can still touch the CTS |
+| Defer client WINDOW_UPDATE until after SETTINGS | HttpClient / MSN / Wikipedia treat pre-SETTINGS WINDOW_UPDATE as PROTOCOL_ERROR |
+| Rapid-reset GOAWAY uses `ClientResetBudgetLastStreamId`; do not return | CVE-2023-44487: already-admitted streams must drain |
+| HPACK decoder resized, never recreated | Recreating discarded dynamic-table entries → `ERR_HTTP2_COMPRESSION_ERROR` |
+| Lite H2 finish stays inline (not `Task.Run`) | Measured ~22k streams/s tax when offloaded |
+| `CertificateHandler` `IsCompletedSuccessfully` outer guard | `.Wait()` on `Task.CompletedTask` parked a worker on the handshake path |
+| H3 `FlushAsync` before FIN | Darwin MsQuic; skip-Flush dropped reverse RPS |
+| H3 drain-to-FIN before Dispose | Otherwise MsQuic RSTs and poisons the pool |
+| QPACK Base math only when `requiredInsertCount != 0` | Default static-only hot path must stay allocation-free |
+| Queue GOAWAY/RST/DATA rather than a direct locked write | Direct write raced MITM HEADERS; Chrome saw DATA on idle streams |
---
-*Last updated: 2026-09-15 — principal architect review, commit `d11d03d9`; QPACK dynamic-table fail-fast guard added (QpackDecoder.cs)*
+*Last updated: 2026-09-16 — all 35 findings closed (implemented or intentional).*
From bec25e55cdb151004969003e5ce3bbca1d5e24dd Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:34:13 +0900
Subject: [PATCH 12/63] Add Http2RelayValidation policy family for opt-in HPACK
semantic validation on the relay path
---
.../Config/ServerConfigApplier.cs | 3 ++-
.../Models/ServerConfig.cs | 2 ++
.../TwpConfigValidator.cs | 1 +
src/Titanium.Web.Proxy/Logging/ProxyLog.cs | 3 ++-
.../Options/PolicyFamily.cs | 24 ++++++++++++++++++-
.../Options/ProxyPolicyModes.cs | 9 ++++---
.../Options/ProxyProfileSettings.cs | 13 ++++++++--
src/Titanium.Web.Proxy/ProxyServer.cs | 7 ++++--
.../LoggingTests.cs | 3 ++-
.../ProxyPolicyModesTests.cs | 11 ++++++---
.../ProxyProfileSettingsTests.cs | 3 +++
.../ProxyServerTests.cs | 2 ++
12 files changed, 67 insertions(+), 14 deletions(-)
diff --git a/src/Titanium.Cli/Config/ServerConfigApplier.cs b/src/Titanium.Cli/Config/ServerConfigApplier.cs
index d4f4a14ef..be23014d9 100644
--- a/src/Titanium.Cli/Config/ServerConfigApplier.cs
+++ b/src/Titanium.Cli/Config/ServerConfigApplier.cs
@@ -343,7 +343,8 @@ private static void ApplyPolicyModes(ProxyServer proxy, PolicyModesConfig? polic
ParsePolicyMode(policy.DecompressionRatio, current[PolicyFamily.DecompressionRatio]),
ParsePolicyMode(policy.HeaderLimits, current[PolicyFamily.HeaderLimits]),
ParsePolicyMode(policy.AdmissionControl, current[PolicyFamily.AdmissionControl]),
- ParsePolicyMode(policy.Http2AbuseBudget, current[PolicyFamily.Http2AbuseBudget]));
+ ParsePolicyMode(policy.Http2AbuseBudget, current[PolicyFamily.Http2AbuseBudget]),
+ ParsePolicyMode(policy.Http2RelayValidation, current[PolicyFamily.Http2RelayValidation]));
if (policy.AllowAmbiguousFraming == true)
{
diff --git a/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs b/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs
index e10c63b27..035d6647b 100644
--- a/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs
+++ b/src/Titanium.Web.Proxy.Configuration/Models/ServerConfig.cs
@@ -204,6 +204,8 @@ public sealed class PolicyModesConfig
public string? Http2AbuseBudget { get; set; }
+ public string? Http2RelayValidation { get; set; }
+
public bool? AllowAmbiguousFraming { get; set; }
}
diff --git a/src/Titanium.Web.Proxy.Configuration/TwpConfigValidator.cs b/src/Titanium.Web.Proxy.Configuration/TwpConfigValidator.cs
index 093b8b929..a2006b6f6 100644
--- a/src/Titanium.Web.Proxy.Configuration/TwpConfigValidator.cs
+++ b/src/Titanium.Web.Proxy.Configuration/TwpConfigValidator.cs
@@ -267,6 +267,7 @@ private static void ValidatePolicyModes(PolicyModesConfig? policy, List
RequireKnownPolicy(policy.HeaderLimits, "server.policyModes.headerLimits", errors);
RequireKnownPolicy(policy.AdmissionControl, "server.policyModes.admissionControl", errors);
RequireKnownPolicy(policy.Http2AbuseBudget, "server.policyModes.http2AbuseBudget", errors);
+ RequireKnownPolicy(policy.Http2RelayValidation, "server.policyModes.http2RelayValidation", errors);
}
private static void ValidateUpstream(UpstreamConfig? upstream, List errors)
diff --git a/src/Titanium.Web.Proxy/Logging/ProxyLog.cs b/src/Titanium.Web.Proxy/Logging/ProxyLog.cs
index 065eed8a0..1c6bd36de 100644
--- a/src/Titanium.Web.Proxy/Logging/ProxyLog.cs
+++ b/src/Titanium.Web.Proxy/Logging/ProxyLog.cs
@@ -125,13 +125,14 @@ internal static void EffectiveProfileAtStartup(ILogger logger, Options.ProxyProf
{
if (!logger.IsEnabled(LogLevel.Information)) return;
logger.LogInformation(
- "Starting with profile {Profile} (body={Body}, decompressionRatio={DecompressionRatio}, headerLimits={HeaderLimits}, admission={Admission}, http2AbuseBudget={Http2AbuseBudget}, allowAmbiguousFraming={AllowAmbiguousFraming}).",
+ "Starting with profile {Profile} (body={Body}, decompressionRatio={DecompressionRatio}, headerLimits={HeaderLimits}, admission={Admission}, http2AbuseBudget={Http2AbuseBudget}, http2RelayValidation={Http2RelayValidation}, allowAmbiguousFraming={AllowAmbiguousFraming}).",
profile,
policyModes[Options.PolicyFamily.BodyBudget],
policyModes[Options.PolicyFamily.DecompressionRatio],
policyModes[Options.PolicyFamily.HeaderLimits],
policyModes[Options.PolicyFamily.AdmissionControl],
policyModes[Options.PolicyFamily.Http2AbuseBudget],
+ policyModes[Options.PolicyFamily.Http2RelayValidation],
policyModes.AllowAmbiguousFraming);
}
diff --git a/src/Titanium.Web.Proxy/Options/PolicyFamily.cs b/src/Titanium.Web.Proxy/Options/PolicyFamily.cs
index b136581cf..f4e7951eb 100644
--- a/src/Titanium.Web.Proxy/Options/PolicyFamily.cs
+++ b/src/Titanium.Web.Proxy/Options/PolicyFamily.cs
@@ -61,5 +61,27 @@ public enum PolicyFamily
/// HTTP/2 abuse budgets: the open-header-block CONTINUATION frame-count/wall-clock bound and
/// the peer-initiated incomplete-stream-reset budget.
///
- Http2AbuseBudget
+ Http2AbuseBudget,
+
+ ///
+ /// Whether HPACK header blocks on the H2↔H2 compressed-relay path
+ /// (httpInterceptionEnabled = false ) are semantically validated per RFC 9113 §8.3.
+ /// Unlike framing (always enforced, no Observe action), header semantics can be logged
+ /// without corrupting connection state.
+ ///
+ /// (default on )
+ /// skips HPACK decode entirely — maximum throughput when upstream peers are trusted.
+ ///
+ ///
+ /// decodes and records semantic violations without
+ /// rejecting the stream. Does not mutate headers, so the compressed-relay
+ /// MutationCount fast path is unaffected when the family is Disabled.
+ ///
+ ///
+ /// (default on
+ /// and ) decodes and sends
+ /// GOAWAY(PROTOCOL_ERROR) on violations.
+ ///
+ ///
+ Http2RelayValidation
}
diff --git a/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs b/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs
index ae7f2b2e9..5cde2209d 100644
--- a/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs
+++ b/src/Titanium.Web.Proxy/Options/ProxyPolicyModes.cs
@@ -47,7 +47,8 @@ private ProxyPolicyModes(Dictionary modes, bool allowA
decompressionRatio: PolicyMode.Enforce,
headerLimits: PolicyMode.Enforce,
admissionControl: PolicyMode.Enforce,
- http2AbuseBudget: PolicyMode.Enforce);
+ http2AbuseBudget: PolicyMode.Enforce,
+ http2RelayValidation: PolicyMode.Enforce);
/// Returns the mode selected for .
public PolicyMode this[PolicyFamily family] => modes[family];
@@ -58,7 +59,8 @@ public static ProxyPolicyModes Create(
PolicyMode decompressionRatio,
PolicyMode headerLimits,
PolicyMode admissionControl,
- PolicyMode http2AbuseBudget)
+ PolicyMode http2AbuseBudget,
+ PolicyMode http2RelayValidation = PolicyMode.Disabled)
{
var dict = new Dictionary
{
@@ -66,7 +68,8 @@ public static ProxyPolicyModes Create(
[PolicyFamily.DecompressionRatio] = decompressionRatio,
[PolicyFamily.HeaderLimits] = headerLimits,
[PolicyFamily.AdmissionControl] = admissionControl,
- [PolicyFamily.Http2AbuseBudget] = http2AbuseBudget
+ [PolicyFamily.Http2AbuseBudget] = http2AbuseBudget,
+ [PolicyFamily.Http2RelayValidation] = http2RelayValidation
};
return new ProxyPolicyModes(dict, false);
}
diff --git a/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs b/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs
index b353464f0..1d68d6b83 100644
--- a/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs
+++ b/src/Titanium.Web.Proxy/Options/ProxyProfileSettings.cs
@@ -68,7 +68,15 @@ private ProxyProfileSettings()
{
ResourceLimits = ProxyResourceLimits.Default.WithCertificateCacheBounds(
maxCertificateCacheEntries: 1024, maxCertificateDiskCacheEntries: null),
- PolicyModes = ProxyPolicyModes.AllEnforce,
+ // Resource families stay Enforce (same as AllEnforce). Http2RelayValidation is
+ // Disabled so H2↔H2 compressed relay remains the verbatim-HPACK fast path.
+ PolicyModes = ProxyPolicyModes.Create(
+ bodyBudget: PolicyMode.Enforce,
+ decompressionRatio: PolicyMode.Enforce,
+ headerLimits: PolicyMode.Enforce,
+ admissionControl: PolicyMode.Enforce,
+ http2AbuseBudget: PolicyMode.Enforce,
+ http2RelayValidation: PolicyMode.Disabled),
SupportedSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
BlockPrivateNetworkDestinations = false,
MaxConcurrentClientConnections = null,
@@ -102,7 +110,8 @@ private ProxyProfileSettings()
decompressionRatio: PolicyMode.Enforce,
headerLimits: PolicyMode.Observe,
admissionControl: PolicyMode.Observe,
- http2AbuseBudget: PolicyMode.Observe),
+ http2AbuseBudget: PolicyMode.Observe,
+ http2RelayValidation: PolicyMode.Disabled),
#pragma warning disable SYSLIB0039 // Deliberate legacy-TLS opt-in for 4.x migrators, per this profile's purpose.
SupportedSslProtocols = SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, // NOSONAR S4423 - Compatible profile intentionally enables TLS 1.0/1.1 for migration
#pragma warning restore SYSLIB0039
diff --git a/src/Titanium.Web.Proxy/ProxyServer.cs b/src/Titanium.Web.Proxy/ProxyServer.cs
index 511f8b953..cb0558f68 100644
--- a/src/Titanium.Web.Proxy/ProxyServer.cs
+++ b/src/Titanium.Web.Proxy/ProxyServer.cs
@@ -1131,7 +1131,10 @@ internal SemaphoreSlim Http2ToHttp11HttpsOriginCreateGate
/// "runtime switch to drop to Observe without redeploying" the plan requires; see
/// for the one-call way to do that.
///
- /// Defaults to , matching .
+ /// Defaults to 's modes (resource families enforced,
+ /// disabled so compressed H2 relay stays
+ /// the verbatim-HPACK fast path). additionally
+ /// enforces relay validation and is what applies.
/// Assigning also replaces this value with that profile's bundle;
/// assign afterward to deviate from the selected profile's modes
/// without changing anything else the profile set.
@@ -1143,7 +1146,7 @@ public ProxyPolicyModes PolicyModes
set => policyModes = value ?? throw new ArgumentNullException(nameof(value));
}
- private ProxyPolicyModes policyModes = ProxyPolicyModes.AllEnforce;
+ private ProxyPolicyModes policyModes = ProxyProfileSettings.Balanced.PolicyModes;
///
/// The last profile applied via this property's setter, defaulting to
diff --git a/tests/Titanium.Web.Proxy.UnitTests/LoggingTests.cs b/tests/Titanium.Web.Proxy.UnitTests/LoggingTests.cs
index 2e0cb07e5..33f624077 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/LoggingTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/LoggingTests.cs
@@ -439,7 +439,8 @@ public void ProxyLog_EffectiveProfileAtStartup_LogsProfileAndPolicyModes()
PolicyMode.Observe,
PolicyMode.Disabled,
PolicyMode.Enforce,
- PolicyMode.Observe);
+ PolicyMode.Observe,
+ PolicyMode.Disabled);
ProxyLog.EffectiveProfileAtStartup(capturing, ProxyProfile.PublicFacing, modes);
diff --git a/tests/Titanium.Web.Proxy.UnitTests/ProxyPolicyModesTests.cs b/tests/Titanium.Web.Proxy.UnitTests/ProxyPolicyModesTests.cs
index 7f52dfa44..df7eefb6d 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/ProxyPolicyModesTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/ProxyPolicyModesTests.cs
@@ -16,6 +16,7 @@ public void AllEnforce_HasEveryFamilyEnforcedAndAmbiguousFramingDisabled()
Assert.AreEqual(PolicyMode.Enforce, modes[PolicyFamily.HeaderLimits]);
Assert.AreEqual(PolicyMode.Enforce, modes[PolicyFamily.AdmissionControl]);
Assert.AreEqual(PolicyMode.Enforce, modes[PolicyFamily.Http2AbuseBudget]);
+ Assert.AreEqual(PolicyMode.Enforce, modes[PolicyFamily.Http2RelayValidation]);
Assert.IsFalse(modes.AllowAmbiguousFraming);
}
@@ -27,13 +28,15 @@ public void Create_AssignsEachFamilyIndependently()
decompressionRatio: PolicyMode.Observe,
headerLimits: PolicyMode.Disabled,
admissionControl: PolicyMode.Observe,
- http2AbuseBudget: PolicyMode.Enforce);
+ http2AbuseBudget: PolicyMode.Enforce,
+ http2RelayValidation: PolicyMode.Observe);
Assert.AreEqual(PolicyMode.Enforce, modes[PolicyFamily.BodyBudget]);
Assert.AreEqual(PolicyMode.Observe, modes[PolicyFamily.DecompressionRatio]);
Assert.AreEqual(PolicyMode.Disabled, modes[PolicyFamily.HeaderLimits]);
Assert.AreEqual(PolicyMode.Observe, modes[PolicyFamily.AdmissionControl]);
Assert.AreEqual(PolicyMode.Enforce, modes[PolicyFamily.Http2AbuseBudget]);
+ Assert.AreEqual(PolicyMode.Observe, modes[PolicyFamily.Http2RelayValidation]);
}
[TestMethod]
@@ -57,7 +60,8 @@ public void WithAllObservedExceptDisabled_DropsEnforceToObserve_ButLeavesDisable
decompressionRatio: PolicyMode.Enforce,
headerLimits: PolicyMode.Disabled,
admissionControl: PolicyMode.Observe,
- http2AbuseBudget: PolicyMode.Enforce);
+ http2AbuseBudget: PolicyMode.Enforce,
+ http2RelayValidation: PolicyMode.Disabled);
var observed = original.WithAllObservedExceptDisabled();
@@ -66,6 +70,7 @@ public void WithAllObservedExceptDisabled_DropsEnforceToObserve_ButLeavesDisable
Assert.AreEqual(PolicyMode.Disabled, observed[PolicyFamily.HeaderLimits]);
Assert.AreEqual(PolicyMode.Observe, observed[PolicyFamily.AdmissionControl]);
Assert.AreEqual(PolicyMode.Observe, observed[PolicyFamily.Http2AbuseBudget]);
+ Assert.AreEqual(PolicyMode.Disabled, observed[PolicyFamily.Http2RelayValidation]);
}
[TestMethod]
@@ -87,7 +92,7 @@ public void Create_NeverAcceptsAllowAmbiguousFraming_AsAParameter()
// snapshot returned by it always starts with AllowAmbiguousFraming == false.
var modes = ProxyPolicyModes.Create(
PolicyMode.Disabled, PolicyMode.Disabled, PolicyMode.Disabled, PolicyMode.Disabled,
- PolicyMode.Disabled);
+ PolicyMode.Disabled, PolicyMode.Disabled);
Assert.IsFalse(modes.AllowAmbiguousFraming);
}
diff --git a/tests/Titanium.Web.Proxy.UnitTests/ProxyProfileSettingsTests.cs b/tests/Titanium.Web.Proxy.UnitTests/ProxyProfileSettingsTests.cs
index 2863abe61..91b443f2a 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/ProxyProfileSettingsTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/ProxyProfileSettingsTests.cs
@@ -42,6 +42,7 @@ public void Balanced_IsAllEnforceWithModernTlsAndNoOutboundBlocking()
Assert.IsNull(settings.MaxConcurrentClientConnections);
Assert.AreEqual(PolicyMode.Enforce, settings.PolicyModes[PolicyFamily.BodyBudget]);
Assert.AreEqual(PolicyMode.Enforce, settings.PolicyModes[PolicyFamily.AdmissionControl]);
+ Assert.AreEqual(PolicyMode.Disabled, settings.PolicyModes[PolicyFamily.Http2RelayValidation]);
Assert.IsFalse(settings.PolicyModes.AllowAmbiguousFraming);
}
@@ -55,6 +56,7 @@ public void LegacyCompatible_ObservesAdmissionAndHeaderLimits_ButStillEnforcesBo
Assert.AreEqual(PolicyMode.Observe, settings.PolicyModes[PolicyFamily.HeaderLimits]);
Assert.AreEqual(PolicyMode.Observe, settings.PolicyModes[PolicyFamily.AdmissionControl]);
Assert.AreEqual(PolicyMode.Observe, settings.PolicyModes[PolicyFamily.Http2AbuseBudget]);
+ Assert.AreEqual(PolicyMode.Disabled, settings.PolicyModes[PolicyFamily.Http2RelayValidation]);
}
[TestMethod]
@@ -80,6 +82,7 @@ public void PublicFacing_IsAllEnforceWithOutboundBlockingAndTighterTimeouts()
Assert.AreEqual(10_000, settings.MaxConcurrentClientConnections);
Assert.AreEqual(PolicyMode.Enforce, settings.PolicyModes[PolicyFamily.BodyBudget]);
Assert.AreEqual(PolicyMode.Enforce, settings.PolicyModes[PolicyFamily.AdmissionControl]);
+ Assert.AreEqual(PolicyMode.Enforce, settings.PolicyModes[PolicyFamily.Http2RelayValidation]);
Assert.IsTrue(settings.ClientHeaderTimeoutSeconds > 0);
Assert.IsTrue(settings.ResponseHeaderTimeoutSeconds > 0);
Assert.IsTrue(settings.IdleReadTimeoutSeconds > 0);
diff --git a/tests/Titanium.Web.Proxy.UnitTests/ProxyServerTests.cs b/tests/Titanium.Web.Proxy.UnitTests/ProxyServerTests.cs
index 9938b98fa..c302dfc8f 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/ProxyServerTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/ProxyServerTests.cs
@@ -132,6 +132,7 @@ public void DefaultConstructor_Profile_IsBalancedWithAllPolicyFamiliesEnforced()
Assert.AreEqual(ProxyProfile.Balanced, proxy.Profile);
Assert.AreEqual(PolicyMode.Enforce, proxy.PolicyModes[PolicyFamily.BodyBudget]);
Assert.AreEqual(PolicyMode.Enforce, proxy.PolicyModes[PolicyFamily.AdmissionControl]);
+ Assert.AreEqual(PolicyMode.Disabled, proxy.PolicyModes[PolicyFamily.Http2RelayValidation]);
Assert.IsFalse(proxy.PolicyModes.AllowAmbiguousFraming);
Assert.IsFalse(proxy.BlockPrivateNetworkDestinations);
}
@@ -158,6 +159,7 @@ public void SettingProfile_ToLegacyCompatible_ThenBack_ToBalanced_RestoresAllEnf
proxy.Profile = ProxyProfile.Balanced;
Assert.AreEqual(PolicyMode.Enforce, proxy.PolicyModes[PolicyFamily.AdmissionControl]);
+ Assert.AreEqual(PolicyMode.Disabled, proxy.PolicyModes[PolicyFamily.Http2RelayValidation]);
Assert.IsFalse(proxy.BlockPrivateNetworkDestinations);
}
From cf0f6078ecd51f910f7b7d2e3723f127d2b597cf Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:36:19 +0900
Subject: [PATCH 13/63] Gate the H2 compressed relay path on the
Http2RelayValidation policy; add Observe and Enforce handling
---
.../Http2/Http2ConnectionState.cs | 8 +
.../Http2/Http2Helper.Copy.Headers.cs | 151 ++++++++++++------
.../Http2/Http2Helper.Copy.cs | 5 +-
src/Titanium.Web.Proxy/Http2/Http2Helper.cs | 12 ++
4 files changed, 122 insertions(+), 54 deletions(-)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs b/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs
index 6b5a73d4b..297124f68 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2ConnectionState.cs
@@ -6,6 +6,7 @@
using System.Threading.Tasks;
using Titanium.Web.Proxy.EventArguments;
using Titanium.Web.Proxy.Helpers;
+using Titanium.Web.Proxy.Options;
namespace Titanium.Web.Proxy.Http2;
@@ -109,6 +110,13 @@ public Http2ConnectionState(long connectionId, CancellationTokenSource cancellat
///
public int LastClientStreamId;
+ ///
+ /// Snapshot of for this connection.
+ /// keeps H2↔H2 compressed relay (verbatim HPACK).
+ /// Observe/Enforce decode header blocks on the no-interception path.
+ ///
+ public PolicyMode Http2RelayValidation;
+
///
/// Per-stream multipart observers for h2 multipart/form-data boundary-aware streaming.
/// Only populated for streams that have a
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
index 725410517..fde36d5e5 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
@@ -153,27 +153,33 @@ internal partial class Http2Helper
if (headerListener.HasMalformedHeader)
{
- // RFC 7540 ?8.1.2/?8.1.2.1: unknown pseudo-header fields, uppercase field names, and
- // (checked just below) connection-specific header fields are malformed - a stream-level
- // PROTOCOL_ERROR that must not tear down the rest of the connection, whose HPACK hpack.Decoder
- // state has already been kept in sync by the decode above.
- ReportException(logger, new ProxyHttpException(
- "HTTP/2 protocol error: " + headerListener.MalformedReason, null, sessionArgs));
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId,
- Http2ErrorCode.ProtocolError, input));
- return false;
+ var malformed = "HTTP/2 protocol error: " + headerListener.MalformedReason;
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, malformed))
+ {
+ // RFC 7540 ?8.1.2/?8.1.2.1: unknown pseudo-header fields, uppercase field names, and
+ // (checked just below) connection-specific header fields are malformed - a stream-level
+ // PROTOCOL_ERROR that must not tear down the rest of the connection, whose HPACK hpack.Decoder
+ // state has already been kept in sync by the decode above.
+ ReportException(logger, new ProxyHttpException(malformed, null, sessionArgs));
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId,
+ Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
var forbiddenConnectionHeader = collected.FirstOrDefault(header =>
ForbiddenConnectionSpecificHeaders.Contains(header.Name));
if (forbiddenConnectionHeader != null)
{
- ReportException(logger, new ProxyHttpException(
- "HTTP/2 protocol error: connection-specific header field '" + forbiddenConnectionHeader.Name +
- "' is forbidden.", null, sessionArgs));
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId,
- Http2ErrorCode.ProtocolError, input));
- return false;
+ var forbidden = "HTTP/2 protocol error: connection-specific header field '" + forbiddenConnectionHeader.Name +
+ "' is forbidden.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, forbidden))
+ {
+ ReportException(logger, new ProxyHttpException(forbidden, null, sessionArgs));
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId,
+ Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
// RFC 9113 §8.5: once an extended CONNECT tunnel is established, no HEADERS or CONTINUATION
@@ -206,13 +212,16 @@ internal partial class Http2Helper
// RFC 8441 §5: :protocol is only valid on CONNECT requests.
if (!isConnect && headerListener.Protocol.Length > 0)
{
- ReportException(logger, new ProxyHttpException(
- "HTTP/2 protocol error: :protocol pseudo-header is only allowed on CONNECT requests.",
- null, sessionArgs));
- removeAndFinalizeStream(hbStreamId);
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
- hbStreamId, Http2ErrorCode.ProtocolError, input));
- return false;
+ var protocolErr =
+ "HTTP/2 protocol error: :protocol pseudo-header is only allowed on CONNECT requests.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, protocolErr))
+ {
+ ReportException(logger, new ProxyHttpException(protocolErr, null, sessionArgs));
+ removeAndFinalizeStream(hbStreamId);
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
+ hbStreamId, Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
if (isMainHeaders)
@@ -258,14 +267,16 @@ internal partial class Http2Helper
}
else if (!isConnect && headerListener.Scheme == string.Empty)
{
- // RFC 7540 §8.1.2.3: non-CONNECT requests must include :scheme.
- ReportException(logger, new ProxyHttpException(
- "HTTP/2 protocol error: request HEADERS missing required :scheme pseudo-header.",
- null, sessionArgs));
- removeAndFinalizeStream(hbStreamId);
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
- hbStreamId, Http2ErrorCode.ProtocolError, input));
- return false;
+ var schemeErr =
+ "HTTP/2 protocol error: request HEADERS missing required :scheme pseudo-header.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, schemeErr))
+ {
+ ReportException(logger, new ProxyHttpException(schemeErr, null, sessionArgs));
+ removeAndFinalizeStream(hbStreamId);
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
+ hbStreamId, Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
// RFC 7540 ?5.1.1: client-initiated stream ids must be odd and strictly increasing
@@ -865,12 +876,15 @@ await RelayCompressedHeaderBlockAsync(
!IsAsciiDigit(statusSpan[1]) ||
!IsAsciiDigit(statusSpan[2]))
{
- ReportException(logger, new ProxyHttpException(
- "HTTP/2 protocol error: :status pseudo-header is not exactly three ASCII digits.",
- null, sessionArgs));
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
- hbStreamId, Http2ErrorCode.ProtocolError, input));
- return false;
+ var statusErr =
+ "HTTP/2 protocol error: :status pseudo-header is not exactly three ASCII digits.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, statusErr))
+ {
+ ReportException(logger, new ProxyHttpException(statusErr, null, sessionArgs));
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
+ hbStreamId, Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
statusCode = (statusSpan[0] - '0') * 100
@@ -1374,12 +1388,15 @@ await RelayCompressedHeaderBlockAsync(
// pseudo-field (RFC 7540 §8.1.2.4).
if (headerRr.HttpVersion < HttpHeader.Version20)
{
- ReportException(logger, new ProxyHttpException(
- "HTTP/2 protocol error: response HEADERS missing required :status pseudo-header.",
- null, sessionArgs));
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
- hbStreamId, Http2ErrorCode.ProtocolError, input));
- return false;
+ var statusMissing =
+ "HTTP/2 protocol error: response HEADERS missing required :status pseudo-header.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, statusMissing))
+ {
+ ReportException(logger, new ProxyHttpException(statusMissing, null, sessionArgs));
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
+ hbStreamId, Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
// RFC 7540 §8.1.2.1: trailer HEADERS MUST NOT contain pseudo-header fields.
@@ -1387,12 +1404,15 @@ await RelayCompressedHeaderBlockAsync(
headerListener.Status.Length > 0 || headerListener.Authority.Length > 0 ||
headerListener.Scheme != string.Empty)
{
- ReportException(logger, new ProxyHttpException(
- "HTTP/2 protocol error: response trailer HEADERS contains pseudo-header fields.",
- null, sessionArgs));
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
- hbStreamId, Http2ErrorCode.ProtocolError, input));
- return false;
+ var trailerPseudo =
+ "HTTP/2 protocol error: response trailer HEADERS contains pseudo-header fields.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, trailerPseudo))
+ {
+ ReportException(logger, new ProxyHttpException(trailerPseudo, null, sessionArgs));
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
+ hbStreamId, Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
// RFC 9110 §6.5.1: certain fields are forbidden in trailers.
@@ -1400,12 +1420,16 @@ await RelayCompressedHeaderBlockAsync(
ForbiddenTrailerHeaders.Contains(header.Name));
if (forbiddenTrailerHeader != null)
{
- ReportException(logger, new ProxyHttpException(
+ var trailerForbidden =
"HTTP/2 protocol error: response trailer HEADERS contains forbidden field '" +
- forbiddenTrailerHeader.Name + "'.", null, sessionArgs));
- await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
- hbStreamId, Http2ErrorCode.ProtocolError, input));
- return false;
+ forbiddenTrailerHeader.Name + "'.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, trailerForbidden))
+ {
+ ReportException(logger, new ProxyHttpException(trailerForbidden, null, sessionArgs));
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
+ hbStreamId, Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
}
foreach (var header in collected)
@@ -1421,6 +1445,27 @@ await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeade
}
}
+ ///
+ /// Observe mode on the no-interception path logs HPACK semantic violations without RST/GOAWAY
+ /// and without mutating headers (so MutationCount / compressed-relay finish stays valid if the
+ /// family is later switched). MITM (interception on) always enforces.
+ ///
+ private static bool EnforceHttp2RelayHeaderSemantics(
+ Http2ConnectionState connectionState, bool httpInterceptionEnabled, ILogger logger, string detail)
+ {
+ if (httpInterceptionEnabled)
+ return true;
+
+ var mode = connectionState.Http2RelayValidation;
+ if (mode != PolicyMode.Disabled)
+ {
+ ProxyMetrics.PolicyBreach(PolicyFamily.Http2RelayValidation, mode);
+ ProxyLog.PolicyBreach(logger, PolicyFamily.Http2RelayValidation, mode, detail);
+ }
+
+ return mode != PolicyMode.Observe;
+ }
+
private static string InternCommonHttpMethod(ReadOnlySpan methodSpan, ByteString method)
{
if (methodSpan.SequenceEqual("GET"u8)) return "GET";
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
index ff5da4203..84cf25b52 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
@@ -78,7 +78,10 @@ private static async Task CopyHttp2FrameAsync(Stream input, Stream output, // NO
bool canCompressedRelayTopology = !enableRfc8441
&& output is not NullOriginStream
&& input is not NullOriginStream;
- bool useCompressedRelay = canCompressedRelayTopology && !httpInterceptionEnabled;
+ var relayValidation = connectionState.Http2RelayValidation;
+ bool useCompressedRelay = canCompressedRelayTopology
+ && !httpInterceptionEnabled
+ && relayValidation == PolicyMode.Disabled;
bool forceStaticHpackTable = useCompressedRelay
|| (canCompressedRelayTopology && httpInterceptionEnabled
&& forceStaticHpackForMitmUnchangedRelay);
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs
index 5f3887a0c..1d10b9c07 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs
@@ -228,6 +228,17 @@ internal static async Task SendHttp2(Stream clientStream, Stream serverStream, /
var connectionState = new Http2ConnectionState(connectionId, cancellationTokenSource,
resourceLimits.MaxConcurrentStreamsPerConnection);
+ try
+ {
+ using var policyProbe = sessionFactory();
+ connectionState.Http2RelayValidation =
+ policyProbe.Server.PolicyModes[PolicyFamily.Http2RelayValidation];
+ }
+ catch
+ {
+ connectionState.Http2RelayValidation = PolicyMode.Disabled;
+ }
+
// Dedicated writers (share the direction locks so control-frame paths cannot interleave).
connectionState.ClientFrameWriter =
new Http2FrameWriter(clientStream, connectionState.ClientWriteLock);
@@ -249,6 +260,7 @@ internal static async Task SendHttp2(Stream clientStream, Stream serverStream, /
// Tip A/B (MITM MaxOrigin=2 + pool): Lite err%~29 / RSS blow-up — keep disabled.
var useMultiOrigin = canCompressedRelayTopology
&& !httpInterceptionEnabled
+ && connectionState.Http2RelayValidation == PolicyMode.Disabled
&& openOriginConnectionAsync != null
&& resourceLimits.MaxOriginHttp2ConnectionsPerAuthority > 1;
From f258834d9ced726c867fd029440ea11d85187f34 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:39:11 +0900
Subject: [PATCH 14/63] Use the highest admitted stream ID in all GOAWAY
Last-Stream-ID fields
---
.../Http2/Http2Helper.Copy.cs | 34 +++++++++----------
1 file changed, 17 insertions(+), 17 deletions(-)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
index 84cf25b52..a26199e2c 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
@@ -507,7 +507,7 @@ async Task TrySendGracefulGoAwayAsync()
ReportException(logger, new ProxyHttpException(
$"HTTP/2 protocol error: expected a SETTINGS frame immediately after the connection preface, got {type}.",
null, null));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.ProtocolError, input));
return;
}
@@ -521,7 +521,7 @@ async Task TrySendGracefulGoAwayAsync()
ReportException(logger, new ProxyHttpException(
$"HTTP/2 protocol error: frame of type {type} exceeded the maximum accepted frame size.",
null, null));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.FrameSizeError, input));
// Unlike every other rejection path here, this one fires before the frame's payload is
// ever read (see the ForceRead call right below this block) - drain it now so the GOAWAY
@@ -538,7 +538,7 @@ async Task TrySendGracefulGoAwayAsync()
// stream-specific; stream id 0 on any of them is a connection-level PROTOCOL_ERROR.
ReportException(logger, new ProxyHttpException(
$"HTTP/2 protocol error: frame of type {type} received with stream id 0.", null, null));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.ProtocolError, input));
return;
}
@@ -709,7 +709,7 @@ await assignment.Leg.SendFlow
ReportException(logger, new ProxyHttpException(
$"HTTP/2 protocol error: unexpected PUSH_PROMISE frame from the {(isClient ? "client" : "server")}.",
null, null));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.ProtocolError, input));
return;
}
@@ -846,7 +846,7 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(
"HTTP/2 protocol error: HEADERS frame received while a previous header block on this connection was still open.",
null, null));
await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
- pendingHeaderStreamId, Http2ErrorCode.ProtocolError, input));
+ connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input));
return;
}
@@ -915,7 +915,7 @@ await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength),
"HTTP/2 protocol error: HEADERS frame received while a previous header block on this connection was still open.",
null, args));
await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
- pendingHeaderStreamId, Http2ErrorCode.ProtocolError, input));
+ connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input));
return;
}
@@ -970,7 +970,7 @@ await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength),
{
ReportException(logger, new ProxyHttpException(
"HTTP/2 protocol error: unexpected CONTINUATION frame.", null, args));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.ProtocolError, input));
return;
}
@@ -980,7 +980,7 @@ await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength),
ReportException(logger, new ProxyHttpException(
"HTTP/2 header block exceeded the maximum allowed compressed size.", null,
pendingHeaderArgs));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.EnhanceYourCalm, input));
return;
}
@@ -1010,7 +1010,7 @@ await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength),
"HTTP/2 header block exceeded the maximum allowed CONTINUATION frame count or " +
"stayed open too long - possible CONTINUATION flood.", null, pendingHeaderArgs));
await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
- streamId, Http2ErrorCode.EnhanceYourCalm, input));
+ connectionState.LastClientStreamId, Http2ErrorCode.EnhanceYourCalm, input));
return;
}
}
@@ -1413,7 +1413,7 @@ await QueueSendData(connectionState, towardServer: isClient, outputWriteLock,
{
ReportException(logger, new ProxyHttpException(
"HTTP/2 protocol error: WINDOW_UPDATE frame with invalid length.", null, args));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.FrameSizeError, input));
return;
}
@@ -1425,7 +1425,7 @@ await QueueSendData(connectionState, towardServer: isClient, outputWriteLock,
// stream id 0) of type PROTOCOL_ERROR.
if (streamId == 0)
{
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.ProtocolError, input));
return;
}
@@ -1455,7 +1455,7 @@ await QueueSendData(connectionState, towardServer: isClient, outputWriteLock,
null, args));
if (flowStreamId == 0)
{
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.FlowControlError, input));
return;
}
@@ -1473,7 +1473,7 @@ await QueueSendData(connectionState, towardServer: isClient, outputWriteLock,
{
ReportException(logger, new ProxyHttpException(
"HTTP/2 protocol error: PING frame with invalid length.", null, args));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.FrameSizeError, input));
return;
}
@@ -1553,7 +1553,7 @@ await lockedOwnLegWrite(async () =>
// 6.5. SETTINGS
// A SETTINGS frame with a length other than a multiple of 6 octets MUST be treated as a connection error (Section 5.4.1) of type FRAME_SIZE_ERROR
ReportException(logger, new ProxyHttpException("Invalid settings length", null, null));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.FrameSizeError, input));
return;
}
@@ -1565,7 +1565,7 @@ await lockedOwnLegWrite(async () =>
// FRAME_SIZE_ERROR."
ReportException(logger, new ProxyHttpException(
"HTTP/2 protocol error: SETTINGS ACK frame with non-zero length.", null, null));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.FrameSizeError, input));
return;
}
@@ -1764,7 +1764,7 @@ await lockedOwnLegWrite(async () =>
{
ReportException(logger, new ProxyHttpException(
"HTTP/2 protocol error: SETTINGS frame contained an out-of-range value.", null, null));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
invalidSettingsError, input));
return;
}
@@ -1863,7 +1863,7 @@ await lockedOwnLegWrite(async () =>
{
ReportException(logger, new ProxyHttpException(
"HTTP/2 protocol error: RST_STREAM frame with invalid length.", null, args));
- await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId,
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId,
Http2ErrorCode.FrameSizeError, input));
return;
}
From 71be2ca773b6651a7bb1471f6938ea8932de39de Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:39:37 +0900
Subject: [PATCH 15/63] Reject SETTINGS, PING and GOAWAY frames that arrive on
a non-zero stream ID
---
src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
index a26199e2c..fce1b92e4 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
@@ -543,6 +543,18 @@ async Task TrySendGracefulGoAwayAsync()
return;
}
+ // RFC 9113 §6.5 / §6.7 / §6.8: SETTINGS, PING, GOAWAY must use stream 0.
+ if ((type == Http2FrameType.Settings || type == Http2FrameType.Ping
+ || type == Http2FrameType.GoAway) && streamId != 0)
+ {
+ ReportException(logger, new ProxyHttpException(
+ $"HTTP/2 protocol error: frame of type {type} received with non-zero stream id {streamId}.",
+ null, null));
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
+ connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input));
+ return;
+ }
+
// Compressed-relay DATA: resolve stream remap + state before reading payload so we can
// ReadExact straight into the rented wire buffer (skip the shared frame `buffer` copy).
if (type == Http2FrameType.Data)
From 6a6cab6d3e9f3c290284657b50d1bb944e4ecbec Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:45:54 +0900
Subject: [PATCH 16/63] Treat oversized frame padding as a connection error
instead of silently clamping it
---
.../Http2/Http2Helper.Copy.cs | 49 ++++++++++++++++---
.../Http2/Http2OriginConnection.cs | 38 +++++++++-----
2 files changed, 67 insertions(+), 20 deletions(-)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
index fce1b92e4..827c11d88 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
@@ -848,10 +848,17 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(
if (priority)
offset += 5;
- int fragmentLength = length - offset - padLength;
- if (fragmentLength < 0)
- fragmentLength = 0;
+ if (padded && offset + padLength > length)
+ {
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 protocol error: HEADERS padding length is the payload length or longer.",
+ null, null));
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
+ connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input));
+ return;
+ }
+ int fragmentLength = length - offset - padLength;
if (pendingHeaderBlock != null)
{
ReportException(logger, new ProxyHttpException(
@@ -912,12 +919,18 @@ await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength),
rr.Priority = priorityData;
}
- int fragmentLength = length - offset - padLength;
- if (fragmentLength < 0)
+ if (padded && offset + padLength > length)
{
- fragmentLength = 0;
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 protocol error: HEADERS padding length is the payload length or longer.",
+ null, args));
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
+ connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input));
+ return;
}
+ int fragmentLength = length - offset - padLength;
+
if (pendingHeaderBlock != null)
{
// RFC 7540 ?6.10: only a CONTINUATION frame for the same stream may follow a
@@ -1311,9 +1324,20 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(
int offset = 0;
if (padded)
{
+ var padLength = buffer[0];
+ if (padLength >= length)
+ {
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 protocol error: DATA padding length is the payload length or longer.",
+ null, args));
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
+ connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input));
+ return;
+ }
+
offset++;
length--;
- length -= buffer[0];
+ length -= padLength;
}
if (data == null)
@@ -1380,9 +1404,18 @@ await lockedOwnLegWrite(() => SendRstStreamAsync(
if (padded)
{
var padLength = buffer[0];
+ if (padLength >= length)
+ {
+ ReportException(logger, new ProxyHttpException(
+ "HTTP/2 protocol error: DATA padding length is the payload length or longer.",
+ null, args));
+ await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9],
+ connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input));
+ return;
+ }
+
dataOffset = 1;
dataLength = length - 1 - padLength;
- if (dataLength < 0) dataLength = 0;
}
var dataBytes = new byte[dataLength];
diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
index 186542bbc..01e5f98fe 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
@@ -1378,16 +1378,26 @@ private static ReadOnlySpan StripHeadersFraming(ReadOnlySpan payload
var offset = 0;
var end = payload.Length;
- if ((flags & Http2FrameFlag.Padded) != 0 && payload.Length > 0)
+ if ((flags & Http2FrameFlag.Padded) != 0)
{
+ if (payload.Length == 0 || payload[0] >= payload.Length)
+ throw new IOException("HTTP/2 protocol error: HEADERS padding length is the payload length or longer.");
var padLength = payload[0];
offset = 1;
- end = Math.Max(offset, payload.Length - padLength);
+ end = payload.Length - padLength;
}
- if ((flags & Http2FrameFlag.Priority) != 0 && end - offset >= 5) offset += 5;
+ if ((flags & Http2FrameFlag.Priority) != 0)
+ {
+ if (end - offset < 5)
+ throw new IOException("HTTP/2 protocol error: HEADERS PRIORITY flag with truncated payload.");
+ offset += 5;
+ }
+
+ if (offset > end)
+ throw new IOException("HTTP/2 protocol error: HEADERS padding length is the payload length or longer.");
- if (offset >= end) return ReadOnlySpan.Empty;
+ if (offset == end) return ReadOnlySpan.Empty;
return payload.Slice(offset, end - offset);
}
@@ -1403,8 +1413,9 @@ private static ReadOnlySpan StripDataFramingSpan(ReadOnlySpan payloa
return payload;
var padLength = payload[0];
- var end = Math.Max(1, payload.Length - padLength);
- return payload.Slice(1, end - 1);
+ if (1 + padLength > payload.Length)
+ throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ return payload.Slice(1, payload.Length - 1 - padLength);
}
/// Strips DATA PADDED framing into a new array (tunnel channel ownership).
@@ -1414,8 +1425,9 @@ private static byte[] StripDataFraming(ReadOnlySpan payload, Http2FrameFla
return payload.ToArray();
var padLength = payload[0];
- var end = Math.Max(1, payload.Length - padLength);
- return payload.Slice(1, end - 1).ToArray();
+ if (1 + padLength > payload.Length)
+ throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ return payload.Slice(1, payload.Length - 1 - padLength).ToArray();
}
///
@@ -1427,8 +1439,9 @@ private static byte[] StripDataFraming(byte[] payload, Http2FrameFlag flags) //
if ((flags & Http2FrameFlag.Padded) == 0 || payload.Length == 0) return payload;
var padLength = payload[0];
- var end = Math.Max(1, payload.Length - padLength);
- return payload.AsSpan(1, end - 1).ToArray();
+ if (1 + padLength > payload.Length)
+ throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ return payload.AsSpan(1, payload.Length - 1 - padLength).ToArray();
}
/// Strips DATA padding while retaining ownership of a pooled payload buffer.
@@ -1439,8 +1452,9 @@ private static ReadOnlyMemory StripDataFramingMemory(
return payload.AsMemory(0, payloadLength);
var padLength = payload[0];
- var end = Math.Max(1, payloadLength - padLength);
- return payload.AsMemory(1, end - 1);
+ if (1 + padLength > payloadLength)
+ throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ return payload.AsMemory(1, payloadLength - 1 - padLength);
}
private void ProcessHeaderBlock(int streamId, ReadOnlySpan compressed, bool endStream) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
From 7b1575e607ee172f169b89d8fd69658779a9b031 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:46:27 +0900
Subject: [PATCH 17/63] Correctly classify HTTP/2 requests that carry :method
but no :path
---
.../Http2/Http2Helper.Copy.Headers.cs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
index fde36d5e5..e5d859159 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs
@@ -206,8 +206,8 @@ internal partial class Http2Helper
bool isConnect = method.Length > 0 &&
method.Span.SequenceEqual(ConnectMethodBytes);
bool isExtendedConnect = isConnect && headerListener.Protocol.Length > 0;
- bool isMainHeaders = (method.Length > 0 && path.Length > 0) ||
- (isConnect && headerListener.Authority.Length > 0);
+ // Treat any :method-bearing block as request headers — never silently reclassify as trailers.
+ bool isMainHeaders = method.Length > 0 || (isConnect && headerListener.Authority.Length > 0);
// RFC 8441 §5: :protocol is only valid on CONNECT requests.
if (!isConnect && headerListener.Protocol.Length > 0)
@@ -279,6 +279,20 @@ internal partial class Http2Helper
}
}
+ if (!isConnect && path.Length == 0)
+ {
+ var pathErr =
+ "HTTP/2 protocol error: request HEADERS missing required :path pseudo-header.";
+ if (EnforceHttp2RelayHeaderSemantics(connectionState, httpInterceptionEnabled, logger, pathErr))
+ {
+ ReportException(logger, new ProxyHttpException(pathErr, null, sessionArgs));
+ removeAndFinalizeStream(hbStreamId);
+ await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9],
+ hbStreamId, Http2ErrorCode.ProtocolError, input));
+ return false;
+ }
+ }
+
// RFC 7540 ?5.1.1: client-initiated stream ids must be odd and strictly increasing
// on a given connection. An even id (reserved for server-initiated streams, which
// this proxy never admits - see the PUSH_PROMISE rejection in the main frame loop)
From ddf6afc7a5fe7c13aca39a181f10b5b22db34c0f Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:46:41 +0900
Subject: [PATCH 18/63] Reject SETTINGS_ENABLE_PUSH values greater than 1
---
src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
index 827c11d88..ad03f4654 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs
@@ -1748,7 +1748,13 @@ await lockedOwnLegWrite(async () =>
else if (identifier == (int)Http2SettingsId.EnablePush)
{
sawEnablePush = true;
- if (isClient)
+ // RFC 9113 §6.5.2: SETTINGS_ENABLE_PUSH MUST be 0 or 1.
+ if (value > 1)
+ {
+ invalidSettings = true;
+ invalidSettingsError = Http2ErrorCode.ProtocolError;
+ }
+ else if (isClient)
{
// This relay never implements server push translation, so the proxy must
// never let the server believe push is welcome on this connection -
From 56c6bbc2c1c6b1d1ee50abc291d64d068d6679a1 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:47:35 +0900
Subject: [PATCH 19/63] Reject HTTP/1 obs-fold header continuations per RFC
9112
---
src/Titanium.Web.Proxy/Http/HeaderParser.cs | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/Titanium.Web.Proxy/Http/HeaderParser.cs b/src/Titanium.Web.Proxy/Http/HeaderParser.cs
index 28c6960b3..5d1c399be 100644
--- a/src/Titanium.Web.Proxy/Http/HeaderParser.cs
+++ b/src/Titanium.Web.Proxy/Http/HeaderParser.cs
@@ -100,6 +100,11 @@ private static async ValueTask TryReadHeadersContinueAsync(HttpStream read
private static void AddHeaderLine(HeaderCollection headerCollection, string tmpLine)
{
+ // RFC 9112 §5.2: obs-fold (field-value continuation with leading SP or HTAB) is forbidden.
+ // Treated as framing — always enforced, no PolicyMode.
+ if (tmpLine.Length > 0 && (tmpLine[0] == ' ' || tmpLine[0] == '\t'))
+ throw new FormatException("HTTP/1.x obs-fold continuation is not permitted (RFC 9112 §5.2).");
+
var colonIndex = tmpLine.IndexOf(':');
if (colonIndex == -1) throw new FormatException("Header line should contain a colon character.");
@@ -120,6 +125,11 @@ private static void AddHeaderLine(HeaderCollection headerCollection, string tmpL
private static void AddHeaderLine(HeaderCollection headerCollection, ReadOnlySpan tmpLine)
{
+ // RFC 9112 §5.2: obs-fold (field-value continuation with leading SP or HTAB) is forbidden.
+ // Treated as framing — always enforced, no PolicyMode.
+ if (tmpLine.Length > 0 && (tmpLine[0] == (byte)' ' || tmpLine[0] == (byte)'\t'))
+ throw new FormatException("HTTP/1.x obs-fold continuation is not permitted (RFC 9112 §5.2).");
+
var colonIndex = tmpLine.IndexOf((byte)':');
if (colonIndex == -1) throw new FormatException("Header line should contain a colon character.");
From b162f89aa9a6f6a6b6e56f93dbcc226420e11499 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:47:36 +0900
Subject: [PATCH 20/63] Run async certificate validation on a background thread
to prevent sync-context deadlocks
---
src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
index 74b598f34..ca775cc40 100644
--- a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
@@ -1,6 +1,7 @@
using System;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
+using System.Threading.Tasks;
using Titanium.Web.Proxy.EventArguments;
using Titanium.Web.Proxy.Extensions;
@@ -28,9 +29,11 @@ internal bool ValidateServerCertificate(object sender, SessionEventArgsBase? ses
// Prefer completing synchronously when handlers return completed tasks — .Wait() on the
// handshake path parked a worker even for Task.CompletedTask (probe loopback CA).
+ // When not already completed, run on the ThreadPool so a user callback that posts back to
+ // a captured SynchronizationContext cannot deadlock the handshake thread.
var pending = ServerCertificateValidationCallback.InvokeAsync(this, args, logger);
if (!pending.IsCompletedSuccessfully)
- pending.GetAwaiter().GetResult();
+ Task.Run(() => pending.AsTask()).GetAwaiter().GetResult();
return args.IsValid;
}
From a46f9e70f4f5f89f3dd6b8e7f81d3758172aa508 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:47:37 +0900
Subject: [PATCH 21/63] Signal H3_MISSING_SETTINGS when an origin control
stream opens with the wrong frame type
---
src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs
index 291ca5ee0..bf15953ed 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs
@@ -164,7 +164,8 @@ private static async Task DrainBytesAsync(QuicStream stream, CancellationToken c
if (!receivedSettings)
{
if (frame.Type != Http3FrameType.Settings)
- return settings; // peer violation; leave connection for request paths to fail/retry
+ throw new Http3ConnectionException(Http3ErrorCode.MissingSettings,
+ "RFC 9114 §6.2.2: first frame on the control stream must be SETTINGS.");
settings = Http3Settings.Parse(frame.Payload.Span);
receivedSettings = true;
continue;
From d7a0e2f01cb7365293c76f0f4aeaa4c4805fa3bd Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:47:37 +0900
Subject: [PATCH 22/63] Fix use-after-dispose of the QUIC connection
cancellation token
---
src/Titanium.Web.Proxy/Handlers/QuicClientHandler.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/Titanium.Web.Proxy/Handlers/QuicClientHandler.cs b/src/Titanium.Web.Proxy/Handlers/QuicClientHandler.cs
index 9ef61de53..cf839c570 100644
--- a/src/Titanium.Web.Proxy/Handlers/QuicClientHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/QuicClientHandler.cs
@@ -141,7 +141,7 @@ private async ValueTask GetQuicServerConnectionOpti
destPort = 443;
}
- using var connectionCts = new CancellationTokenSource();
+ var connectionCts = new CancellationTokenSource();
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectionCts.Token);
var eventArgs = new BeforeQuicAuthenticateEventArgs(
@@ -220,6 +220,7 @@ private async Task AcceptQuicConnectionsAsync(
if (!endPoint.PendingQuicAuthArgs.TryGetValue(connection, out var authArgs))
{
+ // Options callback never completed or auth args were lost — close without CTS ownership.
_ = connection.CloseAsync(0x100, cancellationToken).AsTask();
continue;
}
@@ -240,6 +241,7 @@ private async Task HandleQuicConnectionAsync(
{
await using (connection)
{
+ using var connectionCts = authArgs.TaskCancellationSource;
try
{
await Http3Connection.RunAsync(
From 4b6c7dfaf83302564f5a3ae4ac21361c6ffaf729 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:47:38 +0900
Subject: [PATCH 23/63] Require :path on HTTP/3 requests that are not CONNECT
---
src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
index 7bb7e43a6..a1c29510c 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
@@ -105,8 +105,12 @@ public static async Task HandleAsync( // NOSONAR S3776, CA1068 -- Protocol flow
// Fast path gate is known before session construction when interception is off.
var interceptionOff = !server.NeedsHttpInterception(endPoint);
- var normalizedPath = path ?? "/";
- if (!normalizedPath.StartsWith('/'))
+ var isConnect = method == "CONNECT";
+ if (!isConnect && path is null)
+ throw new Http3StreamException(Http3ErrorCode.MessageError,
+ "RFC 9114 §4.3.1: non-CONNECT requests must include :path.");
+ var normalizedPath = isConnect ? string.Empty : path!;
+ if (!isConnect && !normalizedPath.StartsWith('/'))
normalizedPath = "/" + normalizedPath; // NOSONAR S1075 -- Slash is the HTTP origin-form delimiter, not a filesystem path.
// Session-less H3→origin reverse tiny-GET: no SessionEventArgs / HttpWebClient / Null stream.
From 9745fc428c515bf5f200c5da3d7c5c9b4f741121 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:48:35 +0900
Subject: [PATCH 24/63] Read and forward HTTP/3 response trailers so gRPC
status codes reach the client
---
.../Http3/Http3OriginBridge.Quic.cs | 29 +++++++++++++++++--
.../Http3/Http3RequestStream.cs | 24 +++++++++++++--
2 files changed, 48 insertions(+), 5 deletions(-)
diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs
index 67d0d7028..0d19df04a 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs
@@ -279,7 +279,16 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio
try
{
if (frame.Type == Http3FrameType.Headers)
- break; // trailers
+ {
+ // Decode trailers (grpc-status, grpc-message, checksums).
+ foreach (var (n, v) in QpackDecoder.Decode(frame.Payload.Span))
+ {
+ if (!n.StartsWith(':'))
+ response.TrailingHeaders.AddHeader(new HttpHeader(n, v));
+ }
+
+ break;
+ }
if (frame.Type != Http3FrameType.Data || frame.Payload.Length == 0)
continue;
var toCopy = Math.Min(frame.Payload.Length, bodyBytes.Length - offset);
@@ -338,7 +347,15 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio
try
{
if (frame.Type == Http3FrameType.Headers)
- break; // trailers — ignored for now
+ {
+ foreach (var (n, v) in QpackDecoder.Decode(frame.Payload.Span))
+ {
+ if (!n.StartsWith(':'))
+ response.TrailingHeaders.AddHeader(new HttpHeader(n, v));
+ }
+
+ break;
+ }
if (frame.Type != Http3FrameType.Data || frame.Payload.Length == 0)
continue;
@@ -377,6 +394,14 @@ await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellatio
break;
}
}
+ else if (current.Type == Http3FrameType.Headers)
+ {
+ foreach (var (n, v) in QpackDecoder.Decode(current.Payload.Span))
+ {
+ if (!n.StartsWith(':'))
+ response.TrailingHeaders.AddHeader(new HttpHeader(n, v));
+ }
+ }
}
finally
{
diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
index a1c29510c..db22afe7e 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
@@ -1150,15 +1150,25 @@ private static async Task SendResponseAsync(QuicStream stream, Response response
response.Headers.RemoveHeader("transfer-encoding");
var qpackHeaders = QpackEncoder.EncodeResponse(response, qpackContext);
+ var hasTrailers = response.TrailingHeaders.Count > 0;
if (response.StreamBodyWriter != null && !response.IsBodySent)
{
await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct);
// Http3OriginBridge streams the origin body; drain it as DATA frames (same contract as
// H1 BodyStreamWriter / H2 EmitSyntheticResponseAsync).
+ // Http3DataBodyWriter never FINs (completeWrites stays false) so trailers can follow.
var bodyWriter = new Http3DataBodyWriter(stream);
await response.StreamBodyWriter(bodyWriter, ct);
response.IsBodySent = true;
+ if (hasTrailers)
+ {
+ var trailerBlock = QpackEncoder.Encode(
+ response.TrailingHeaders.Select(h => (h.Name, h.Value)), qpackContext);
+ await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, trailerBlock, ct, completeWrites: true);
+ }
+
+ // Always Flush — Darwin MsQuic requires an explicit Flush before FIN is observed.
await stream.FlushAsync(ct);
return;
}
@@ -1179,10 +1189,11 @@ private static async Task SendResponseAsync(QuicStream stream, Response response
{
body = null;
}
+
// Size-gated HEADERS+DATA coalesce for already-buffered medium/large bodies only
// (lossy / compare-bodies ≥ 16 KiB). Tiny GET keeps separate writes — full coalesce
// there raised cool absolutes and missed Windows CI (latency bundle revert).
- if (body is { Length: >= 16 * 1024 })
+ if (body is { Length: >= 16 * 1024 } && !hasTrailers)
{
await Http3Frame.WriteHeadersAndDataAsync(stream, qpackHeaders, body, ct, completeWrites: true);
await stream.FlushAsync(ct);
@@ -1192,11 +1203,18 @@ private static async Task SendResponseAsync(QuicStream stream, Response response
if (body is { Length: > 0 })
{
await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct);
- await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct, completeWrites: true);
+ await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct, completeWrites: !hasTrailers);
}
else
{
- await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct, completeWrites: true);
+ await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct, completeWrites: !hasTrailers);
+ }
+
+ if (hasTrailers)
+ {
+ var trailerBlock = QpackEncoder.Encode(
+ response.TrailingHeaders.Select(h => (h.Name, h.Value)), qpackContext);
+ await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, trailerBlock, ct, completeWrites: true);
}
await stream.FlushAsync(ct);
From 3144093f5f5256775dcb9aea1a258503980de63c Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:53:27 +0900
Subject: [PATCH 25/63] Implement QPACK dynamic table relative and post-base
index decoding per RFC 9204
---
.../Handlers/CertificateHandler.cs | 2 +-
.../Http3/Http3RequestStream.cs | 3 +-
.../Http3/Qpack/QpackDecoder.cs | 71 ++++++-----
.../Http3/Qpack/QpackEncoder.cs | 7 +-
src/Titanium.Web.Proxy/PublicAPI.Shipped.txt | 3 +-
.../QpackBaseRelativeDecodingTests.cs | 110 ++++++++++++++++++
6 files changed, 165 insertions(+), 31 deletions(-)
create mode 100644 tests/Titanium.Web.Proxy.UnitTests/QpackBaseRelativeDecodingTests.cs
diff --git a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
index ca775cc40..78803720b 100644
--- a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
@@ -33,7 +33,7 @@ internal bool ValidateServerCertificate(object sender, SessionEventArgsBase? ses
// a captured SynchronizationContext cannot deadlock the handshake thread.
var pending = ServerCertificateValidationCallback.InvokeAsync(this, args, logger);
if (!pending.IsCompletedSuccessfully)
- Task.Run(() => pending.AsTask()).GetAwaiter().GetResult();
+ Task.Run(() => pending).GetAwaiter().GetResult();
return args.IsValid;
}
diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
index db22afe7e..ebfcd0c86 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
@@ -1,6 +1,7 @@
#pragma warning disable CA1416
using System;
using System.Collections.Generic;
+using System.Linq;
using System.IO;
using System.Net.Quic;
using System.Threading;
@@ -1150,7 +1151,7 @@ private static async Task SendResponseAsync(QuicStream stream, Response response
response.Headers.RemoveHeader("transfer-encoding");
var qpackHeaders = QpackEncoder.EncodeResponse(response, qpackContext);
- var hasTrailers = response.TrailingHeaders.Count > 0;
+ var hasTrailers = response.HasTrailingHeaders;
if (response.StreamBodyWriter != null && !response.IsBodySent)
{
diff --git a/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs b/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs
index bf84f0e5e..7ade151d1 100644
--- a/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs
+++ b/src/Titanium.Web.Proxy/Http3/Qpack/QpackDecoder.cs
@@ -96,12 +96,10 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Invalid Required Insert Count.");
data = data[consumed..];
- // Parse S bit and Delta Base.
- // The wire format encodes: Base = RequiredInsertCount − (S ? DeltaBase+1 : DeltaBase).
- // Dynamic indexed references use *relative* wire indexes: absoluteIndex = Base−1−wireIndex.
- // Post-base references use: absoluteIndex = Base + wireIndex.
- // TODO (backlog B2-a): correctly decode S + DeltaBase and compute Base so relative and
- // post-base dynamic table lookups resolve the right absolute index per RFC 9204 §4.5.
+ // Parse S bit and Delta Base (RFC 9204 §4.5.1.2).
+ // Base = RequiredInsertCount − (S ? DeltaBase+1 : DeltaBase).
+ // Relative dynamic refs: absoluteIndex = Base − 1 − wireIndex.
+ // Post-base dynamic refs: absoluteIndex = Base + wireIndex.
if (data.IsEmpty)
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Missing Base field.");
var sAndDeltaByte = data[0];
@@ -114,20 +112,25 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
$"Dynamic QPACK table not supported: Required Insert Count = {requiredInsertCount}.");
- // Guard: dynamic table is enabled but Base decoding is not yet implemented correctly.
- // A peer that fills its dynamic table will send requiredInsertCount > 0 and wire indexes
- // that are relative to Base, not absolute. Until the TODO above is resolved, any such
- // block would silently look up the wrong table entry and corrupt headers.
- // Fail-fast with a clear error rather than returning wrong header values.
+ // Wire RIC is an encoded value when non-zero (RFC 9204 §4.5.1.1).
+ ulong resolvedRic = requiredInsertCount;
if (requiredInsertCount != 0 && context != null)
{
- // Compute the nominal Base so future implementers have the value available.
- // base = requiredInsertCount - (sBit ? deltaBase + 1 : deltaBase)
- _ = sBit; _ = deltaBase; // suppress unused-variable warnings until TODO is resolved
- throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
- $"Dynamic QPACK table references (Required Insert Count = {requiredInsertCount}) are not yet " +
- "fully supported. Set ProxyServer.EnableQpackDynamicTable = false (the default) to prevent " +
- "this connection error, or fix the Base-relative index decoding in QpackDecoder.DecodeCore.");
+ resolvedRic = DecodeRequiredInsertCount(
+ requiredInsertCount,
+ context.InboundDecoderTable.InsertCount,
+ context.MaxTableCapacityFromPeer);
+ }
+
+ // Static-only path (resolvedRic == 0): Base is unused; keep the hot path allocation-free.
+ ulong @base = 0;
+ if (resolvedRic > 0)
+ {
+ var subtract = sBit ? deltaBase + 1 : deltaBase;
+ if (subtract > resolvedRic)
+ throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
+ $"QPACK Base underflow: RIC={resolvedRic}, S={sBit}, DeltaBase={deltaBase}.");
+ @base = resolvedRic - subtract;
}
var headers = new List<(string, string)>();
@@ -138,7 +141,7 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
if ((b & 0x80) != 0)
{
- // Indexed Header Field — S=1 static, S=0 dynamic
+ // Indexed Header Field — S=1 static, S=0 dynamic (relative to Base)
var isStatic = (b & 0x40) != 0;
if (!TryReadPrefixedInt(data, 6, out var index, out consumed))
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Invalid indexed field index.");
@@ -154,11 +157,12 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
}
else
{
- if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(index, out string dynName, out string dynValue) == true)
+ var absIndex = ResolveRelativeDynamicIndex(@base, index);
+ if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(absIndex, out string dynName, out string dynValue) == true)
headers.Add((dynName, dynValue));
else
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
- $"Dynamic table absolute index {index} not found.");
+ $"Dynamic table absolute index {absIndex} not found.");
}
}
else if ((b & 0x40) != 0)
@@ -179,11 +183,12 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
}
else
{
- if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(nameIndex, out string dynName, out _) == true)
+ var absIndex = ResolveRelativeDynamicIndex(@base, nameIndex);
+ if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(absIndex, out string dynName, out _) == true)
name = dynName;
else
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
- $"Dynamic table name index {nameIndex} not found.");
+ $"Dynamic table name index {absIndex} not found.");
}
if (!TryReadStringLiteral(data, out var value, out consumed))
@@ -223,15 +228,17 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
else if ((b & 0x10) != 0)
{
// Indexed Header Field (post-base, dynamic): 0 0 0 1 Index(4)
+ // absoluteIndex = Base + wireIndex
if (!TryReadPrefixedInt(data, 4, out var index, out consumed))
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Invalid post-base index.");
data = data[consumed..];
- if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(index, out string pbName, out string pbValue) == true)
+ var absIndex = @base + index;
+ if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(absIndex, out string pbName, out string pbValue) == true)
headers.Add((pbName, pbValue));
else
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
- $"Post-base dynamic index {index} not found.");
+ $"Post-base dynamic index {absIndex} not found.");
}
else
{
@@ -240,12 +247,13 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Invalid post-base name ref.");
data = data[consumed..];
+ var absIndex = @base + nameIndex;
string nameFromDyn;
- if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(nameIndex, out string pbName, out _) == true)
+ if (context?.InboundDecoderTable.TryGetByAbsoluteIndex(absIndex, out string pbName, out _) == true)
nameFromDyn = pbName;
else
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
- $"Post-base dynamic name index {nameIndex} not found.");
+ $"Post-base dynamic name index {absIndex} not found.");
if (!TryReadStringLiteral(data, out var value, out consumed))
throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed, "Invalid post-base literal value.");
@@ -257,6 +265,15 @@ internal static ulong DecodeRequiredInsertCount(ulong encodedRic, ulong insertCo
return headers;
}
+ /// RFC 9204 §4.5.2: absoluteIndex = Base − 1 − relativeIndex.
+ private static ulong ResolveRelativeDynamicIndex(ulong @base, ulong wireIndex)
+ {
+ if (@base == 0 || wireIndex >= @base)
+ throw new Http3ConnectionException(Http3ErrorCode.QpackDecompressionFailed,
+ $"Relative index {wireIndex} out of range for Base={@base}.");
+ return @base - 1 - wireIndex;
+ }
+
private static bool TryReadPrefixedInt(ReadOnlySpan data, int prefixBits, out ulong value, out int consumed)
{
diff --git a/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoder.cs b/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoder.cs
index 6145b2b4d..b9f791a1e 100644
--- a/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoder.cs
+++ b/src/Titanium.Web.Proxy/Http3/Qpack/QpackEncoder.cs
@@ -332,9 +332,14 @@ private static byte[] FinishBlock(MemoryStream body, ulong maxRequiredInsertCoun
}
else
{
+ // Encode RIC on the wire. Pair with S=1, DeltaBase = RIC−1 so Base = 0.
+ // WriteDynamicIndexed / WriteLiteralWithDynamicNameRef emit post-base instructions
+ // whose wire index is the absolute dynamic-table index; with Base=0 those resolve
+ // as abs = Base + wireIndex = absoluteIndex (RFC 9204 §4.5.3 / §4.5.5).
var encodedRic = EncodeRequiredInsertCount(maxRequiredInsertCount, context!.MaxTableCapacityFromPeer);
ricByte = (byte)(encodedRic & 0xFF);
- sByte = 0x00;
+ var deltaBase = maxRequiredInsertCount - 1;
+ sByte = (byte)(0x80 | (deltaBase & 0x7F));
}
var result = new byte[2 + body.Length];
diff --git a/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt
index 918f8b80b..e9fd33412 100644
--- a/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt
+++ b/src/Titanium.Web.Proxy/PublicAPI.Shipped.txt
@@ -103,7 +103,7 @@ static Titanium.Web.Proxy.Models.HttpHeader.Encoding.get -> System.Text.Encoding
static Titanium.Web.Proxy.Models.ProxyAuthenticationContext.Failed() -> Titanium.Web.Proxy.Models.ProxyAuthenticationContext!
static Titanium.Web.Proxy.Models.ProxyAuthenticationContext.Succeeded() -> Titanium.Web.Proxy.Models.ProxyAuthenticationContext!
static Titanium.Web.Proxy.Options.ProxyPolicyModes.AllEnforce.get -> Titanium.Web.Proxy.Options.ProxyPolicyModes!
-static Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode bodyBudget, Titanium.Web.Proxy.Options.PolicyMode decompressionRatio, Titanium.Web.Proxy.Options.PolicyMode headerLimits, Titanium.Web.Proxy.Options.PolicyMode admissionControl, Titanium.Web.Proxy.Options.PolicyMode http2AbuseBudget) -> Titanium.Web.Proxy.Options.ProxyPolicyModes!
+static Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode bodyBudget, Titanium.Web.Proxy.Options.PolicyMode decompressionRatio, Titanium.Web.Proxy.Options.PolicyMode headerLimits, Titanium.Web.Proxy.Options.PolicyMode admissionControl, Titanium.Web.Proxy.Options.PolicyMode http2AbuseBudget, Titanium.Web.Proxy.Options.PolicyMode http2RelayValidation = Titanium.Web.Proxy.Options.PolicyMode.Disabled) -> Titanium.Web.Proxy.Options.ProxyPolicyModes!
static Titanium.Web.Proxy.Options.ProxyProfileSettings.Balanced.get -> Titanium.Web.Proxy.Options.ProxyProfileSettings!
static Titanium.Web.Proxy.Options.ProxyProfileSettings.For(Titanium.Web.Proxy.Options.ProxyProfile profile) -> Titanium.Web.Proxy.Options.ProxyProfileSettings!
static Titanium.Web.Proxy.Options.ProxyProfileSettings.LegacyCompatible.get -> Titanium.Web.Proxy.Options.ProxyProfileSettings!
@@ -669,6 +669,7 @@ Titanium.Web.Proxy.Options.PolicyFamily.BodyBudget = 0 -> Titanium.Web.Proxy.Opt
Titanium.Web.Proxy.Options.PolicyFamily.DecompressionRatio = 1 -> Titanium.Web.Proxy.Options.PolicyFamily
Titanium.Web.Proxy.Options.PolicyFamily.HeaderLimits = 2 -> Titanium.Web.Proxy.Options.PolicyFamily
Titanium.Web.Proxy.Options.PolicyFamily.Http2AbuseBudget = 4 -> Titanium.Web.Proxy.Options.PolicyFamily
+Titanium.Web.Proxy.Options.PolicyFamily.Http2RelayValidation = 5 -> Titanium.Web.Proxy.Options.PolicyFamily
Titanium.Web.Proxy.Options.PolicyMode
Titanium.Web.Proxy.Options.PolicyMode.Disabled = 0 -> Titanium.Web.Proxy.Options.PolicyMode
Titanium.Web.Proxy.Options.PolicyMode.Enforce = 2 -> Titanium.Web.Proxy.Options.PolicyMode
diff --git a/tests/Titanium.Web.Proxy.UnitTests/QpackBaseRelativeDecodingTests.cs b/tests/Titanium.Web.Proxy.UnitTests/QpackBaseRelativeDecodingTests.cs
new file mode 100644
index 000000000..423a6a428
--- /dev/null
+++ b/tests/Titanium.Web.Proxy.UnitTests/QpackBaseRelativeDecodingTests.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Titanium.Web.Proxy.Http3;
+using Titanium.Web.Proxy.Http3.Qpack;
+
+namespace Titanium.Web.Proxy.UnitTests;
+
+///
+/// RFC 9204 Base-relative and post-base dynamic-table decoding.
+///
+[TestClass]
+public class QpackBaseRelativeDecodingTests
+{
+ [TestMethod]
+ public void Decode_StaticOnly_RequiredInsertCountZero_Unchanged()
+ {
+ var encoded = QpackEncoder.Encode([(":status", "200")]);
+ var decoded = QpackDecoder.Decode(encoded.AsSpan());
+
+ Assert.AreEqual(1, decoded.Count);
+ Assert.AreEqual(":status", decoded[0].Name);
+ Assert.AreEqual("200", decoded[0].Value);
+ }
+
+ [TestMethod]
+ public void Decode_RelativeDynamicIndexed_BaseEqualsRic_ResolvesAbsoluteZero()
+ {
+ // RIC=1, S=0, ΔBase=0 → Base=1. Relative wireIndex=0 → abs = Base−1−0 = 0.
+ var context = new QpackContext(4096);
+ context.MaxTableCapacityFromPeer = 4096;
+ context.InboundDecoderTable.Insert("x-custom", "one");
+
+ // Encoded RIC for absolute count=1 with MaxEntries=128 is (1 % 256) + 1 = 2.
+ var block = new byte[]
+ {
+ 0x02, // Required Insert Count (encoded)
+ 0x00, // S=0, DeltaBase=0 → Base=1
+ 0x80 // Indexed dynamic, relative index 0
+ };
+
+ var decoded = QpackDecoder.Decode(block.AsMemory(), context);
+
+ Assert.AreEqual(1, decoded.Count);
+ Assert.AreEqual("x-custom", decoded[0].Name);
+ Assert.AreEqual("one", decoded[0].Value);
+ }
+
+ [TestMethod]
+ public void Decode_PostBaseIndexed_BaseZero_ResolvesAbsoluteZero()
+ {
+ // RIC=1, S=1, ΔBase=0 → Base = 1−(0+1) = 0. Post-base wireIndex=0 → abs=0.
+ var context = new QpackContext(4096);
+ context.MaxTableCapacityFromPeer = 4096;
+ context.InboundDecoderTable.Insert("x-custom", "post");
+
+ var block = new byte[]
+ {
+ 0x02, // encoded RIC for absolute count 1
+ 0x80, // S=1, DeltaBase=0 → Base=0
+ 0x10 // Post-base indexed, wire index 0
+ };
+
+ var decoded = QpackDecoder.Decode(block.AsMemory(), context);
+
+ Assert.AreEqual(1, decoded.Count);
+ Assert.AreEqual("x-custom", decoded[0].Name);
+ Assert.AreEqual("post", decoded[0].Value);
+ }
+
+ [TestMethod]
+ public void Decode_RelativeIndex_WhenBaseIsZero_ThrowsQpackDecompressionFailed()
+ {
+ var context = new QpackContext(4096);
+ context.MaxTableCapacityFromPeer = 4096;
+ context.InboundDecoderTable.Insert("x-custom", "one");
+
+ // Base=0 via S=1 ΔBase=0, but a relative (not post-base) dynamic index is out of range.
+ var block = new byte[]
+ {
+ 0x02,
+ 0x80,
+ 0x80 // relative dynamic index 0 with Base=0
+ };
+
+ var ex = Assert.ThrowsExactly(
+ () => QpackDecoder.Decode(block.AsMemory(), context));
+ Assert.AreEqual(Http3ErrorCode.QpackDecompressionFailed, ex.ErrorCode);
+ }
+
+ [TestMethod]
+ public void EncodeDecode_DynamicTable_RoundTrip()
+ {
+ var context = new QpackContext(4096);
+ context.MaxTableCapacityFromPeer = 4096;
+
+ // Seed outbound table so the encoder can reference the entry, then mirror into inbound
+ // so the decoder can resolve the same absolute index.
+ context.OutboundEncoderTable.Insert("x-roundtrip", "value");
+ context.InboundDecoderTable.Insert("x-roundtrip", "value");
+
+ var headers = new List<(string, string)> { ("x-roundtrip", "value") };
+ var encoded = QpackEncoder.Encode(headers, context);
+ var decoded = QpackDecoder.Decode(encoded.AsMemory(), context);
+
+ Assert.AreEqual(1, decoded.Count);
+ Assert.AreEqual("x-roundtrip", decoded[0].Name);
+ Assert.AreEqual("value", decoded[0].Value);
+ }
+}
From 19f171006e314d438d9d6c269c9df8743e3f2207 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:54:24 +0900
Subject: [PATCH 26/63] Update protocol documentation, security notes and
performance guidance
---
website/docs/performance.md | 7 +++++++
wiki/Protocol-Support.md | 7 ++++---
wiki/Security-Considerations.md | 29 +++++++++++++++++++++++++++++
3 files changed, 40 insertions(+), 3 deletions(-)
diff --git a/website/docs/performance.md b/website/docs/performance.md
index 4236c83a8..2765667c1 100644
--- a/website/docs/performance.md
+++ b/website/docs/performance.md
@@ -42,6 +42,13 @@ Additional real-world tables (wiki only, not plotted here): [Unary gRPC H2→h2c
Product 5×5 reverse/MITM matrices, saturation calibration, heavier reverse (bodies/POST/lossy/TLS/arch), unary gRPC (H2↔H2 and H2→h2c), and WebSocket (H1 Upgrade, dual-TLS H1, RFC 8441 H2) tables live on the [Performance wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance). Profiling notes: [Performance profiling](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance-Profiling).
+## Relay validation and throughput
+
+The default `ProxyProfile.Balanced` keeps `PolicyFamily.Http2RelayValidation = Disabled`, so
+H2↔H2 compressed relay forwards HPACK blocks without a semantic decode pass. Switching to
+`PublicFacing` (or setting `Enforce` yourself) adds a full HPACK decode on that path — re-measure
+with your RPS load probe after enabling if relay throughput matters for your workload.
+
---
*How we measure:* matched GitHub Actions runners (~4 vCPU / 16 GiB) on Windows, Linux, and macOS; Titanium vs YARP, nginx, HAProxy, and Envoy; same client, origin, warmup, duration, and concurrency (sustain at 64). Absolute RPS varies slightly with runner noise.
diff --git a/wiki/Protocol-Support.md b/wiki/Protocol-Support.md
index 77f207715..14297a2ef 100644
--- a/wiki/Protocol-Support.md
+++ b/wiki/Protocol-Support.md
@@ -61,7 +61,7 @@ tunnel). See [RFC 8441](#http2-safety-and-frame-validation) below. QUIC endpoint
|---|---|---|---|---|---|
| Persistent connections / keep-alive | Yes | Yes | Yes (inherent) | Yes (inherent) | `Connection: keep-alive` (1.0) / default (1.1); HTTP/2 multiplexes over one connection; HTTP/3 QUIC connections persist across streams. |
| Chunked transfer-encoding | N/A (no chunked in 1.0) | Yes | N/A (HTTP/2 uses DATA frames, not chunking) | N/A (HTTP/3 uses DATA frames) | Read and write, both request and response, via `HttpStream`. |
-| Chunked trailers (trailing headers) | N/A | Yes | Yes | Yes | See `RequestResponseBase.TrailingHeaders`; forwarded/emitted for HTTP/1.x. For HTTP/2, a second HEADERS block without request/status pseudo-headers is decoded as trailers. For HTTP/3, a trailing HEADERS frame after the final DATA frame is decoded as trailers per RFC 9114 §4.1. |
+| Chunked trailers (trailing headers) | N/A | Yes | Yes | Yes | See `RequestResponseBase.TrailingHeaders`; forwarded/emitted for HTTP/1.x. For HTTP/2, a second HEADERS block without request/status pseudo-headers is decoded as trailers. For HTTP/3, trailing HEADERS after DATA are decoded into `TrailingHeaders` and emitted as a second HEADERS frame to the client (so gRPC `grpc-status` / `grpc-message` reach the client). |
| `Expect: 100-continue` | Yes | Yes | N/A (no equivalent frame flow) | N/A | `ProxyServer.Enable100ContinueBehaviour`. Set `CompatibilityMode100Continue = true` to emit a synthetic `100 Continue` to clients that block on it when `Enable100ContinueBehaviour = false`. |
| Other 1xx interim responses (e.g. 103 Early Hints) | N/A | Yes | Yes | Yes | Relayed for all protocol versions. HTTP/3: `Http3OriginBridge` loops on 1xx responses from the origin (up to 20 per request) and forwards each interim HEADERS frame to the client before the final response. |
| HEADERS/CONTINUATION reassembly and re-splitting | N/A | N/A | Yes | N/A | HTTP/3 uses QPACK (no CONTINUATION frames); multi-frame header blocks do not exist in HTTP/3. |
@@ -149,7 +149,7 @@ The H3-related rows below are the HTTP/3 legs of the [protocol bridges](#protoco
| Feature | Support | Notes |
|---------|---------|-------|
| Inbound HTTP/3 (client → proxy over QUIC) | Yes | `TransparentQuicProxyEndPoint` — QUIC only; explicit QUIC proxying is not yet standardised. |
-| QPACK header compression | Yes | **Static table** (always active): RFC 9204 static-table indexed encoding and literal encoding. **Dynamic table** (opt-in): set `ProxyServer.EnableQpackDynamicTable = true` to enable RFC 9204 §3 dynamic table synchronisation. When enabled, the proxy advertises `SETTINGS_QPACK_MAX_TABLE_CAPACITY = 4096` and `SETTINGS_QPACK_BLOCKED_STREAMS = 0` to the client (it never blocks a stream waiting on dynamic-table insertions); opens the QPACK encoder/decoder unidirectional control streams; tracks per-connection inbound and outbound tables in `QpackDynamicTable` (thread-safe via `ReaderWriterLockSlim`); immediately raises `QPACK_DECOMPRESSION_FAILED` per RFC 9204 §4.5.1.1 if a field section's Required Insert Count is not yet satisfied, rather than waiting for it; and sends Section Acknowledgments on a bounded `Channel` (capacity 1000, `DropNewest` on full). In-flight eviction protection prevents removing a table entry while any open stream holds a reference to it. |
+| QPACK header compression | Yes | **Static table** (always active): RFC 9204 static-table indexed encoding and literal encoding. **Dynamic table** (opt-in): set `ProxyServer.EnableQpackDynamicTable = true` to enable RFC 9204 §3 dynamic table synchronisation. When enabled, the proxy advertises `SETTINGS_QPACK_MAX_TABLE_CAPACITY = 4096` and `SETTINGS_QPACK_BLOCKED_STREAMS = 0` to the client (it never blocks a stream waiting on dynamic-table insertions); opens the QPACK encoder/decoder unidirectional control streams; tracks per-connection inbound and outbound tables in `QpackDynamicTable`; implements RFC 9204 §4.5 Base-relative and post-base index resolution in the decoder; immediately raises `QPACK_DECOMPRESSION_FAILED` if a field section's Required Insert Count is not yet satisfied; and sends Section Acknowledgments on a bounded `Channel`. The static-only path (`requiredInsertCount == 0`, the default) is unchanged for RPS. |
| HTTP/3 frame codec | Yes | HEADERS, DATA, SETTINGS, GOAWAY, and unknown/reserved frame types per RFC 9114. |
| Per-stream request/response lifecycle | Yes | `BeforeRequest`, `BeforeResponse`, `AfterResponse` (exactly once per stream), `Via` header injection, per-stream `UpstreamHttpProtocol` override, `ConnectTimeout` override. |
| Outbound H3→H3 (proxy → origin over QUIC) | Yes | `QuicConnectionPool` leases a live `QuicConnection` per origin; streams are opened per-request. |
@@ -175,8 +175,9 @@ The H3-related rows below are the HTTP/3 legs of the [protocol bridges](#protoco
| `CompatibilityMode100Continue` | `false` | Sends a synthetic `100 Continue` to the client before reading the request body when `Enable100ContinueBehaviour = false`, preventing deadlock with strict `Expect: 100-continue` clients. |
| `EnableRfc8441` | `false` | Enables WebSocket over HTTP/2 (RFC 8441). When enabled, the proxy advertises `ENABLE_CONNECT_PROTOCOL=1` to h2 clients and also bridges HTTP/1.1 `Upgrade: websocket` onto h2 origins (extended CONNECT, or HTTP/1.1 fallback when the origin lacks the setting). For h2 clients: if the origin is HTTP/2 and advertises the setting, native h2↔h2 DATA relay is used; if the origin is HTTP/2 and does not, the stream is reset with `REFUSED_STREAM`; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. |
| `EnableHttp3` | `false` | Enables HTTP/3 (QUIC) support (opt-in, experimental — suppress `TWP001`). See [HTTP-3](HTTP-3) wiki page for full details. |
-| `EnableQpackDynamicTable` | `false` | Enables QPACK dynamic table synchronisation per RFC 9204. Requires `EnableHttp3 = true`. See [HTTP-3](HTTP-3) for details. |
+| `EnableQpackDynamicTable` | `false` | Enables QPACK dynamic table synchronisation per RFC 9204 (full Base-relative decode). Requires `EnableHttp3 = true`. See [HTTP-3](HTTP-3) for details. |
| `EnableHttpsSvcbDnsDiscovery` | inherits `EnableHttp3` | Enables proactive HTTP/3 capability discovery via HTTPS/SVCB DNS queries (RFC 9460). Background-only in Auto mode; first-connection H3 adoption otherwise comes from `Alt-Svc`. |
+| `PolicyModes[Http2RelayValidation]` | `Disabled` (`Balanced`) / `Enforce` (`PublicFacing`) | Controls HPACK semantic validation on the H2↔H2 compressed-relay path when interception is off. `Disabled` = verbatim HPACK relay (maximum throughput). `Observe` = decode and log without rejecting. `Enforce` = decode and `GOAWAY(PROTOCOL_ERROR)` on RFC 9113 §8.3 violations. |
| `DnsServerEndPoint` | OS-configured UDP DNS (best-effort) | UDP endpoint for HTTPS/SVCB queries. Does not honor NRPT/DoH/VPN split-DNS; assign explicitly to override. When none is discoverable, proactive discovery is skipped. |
## Where to look for more detail
diff --git a/wiki/Security-Considerations.md b/wiki/Security-Considerations.md
index 068cf69d1..aba9074f1 100644
--- a/wiki/Security-Considerations.md
+++ b/wiki/Security-Considerations.md
@@ -86,6 +86,35 @@ ignored). It does **not** detect JA3/Akamai by name — TLS failures look like o
session is impossible; seamless recovery relies on a new CONNECT. Learned hosts skip MITM prefetch.
Embedded proxies should leave the flag off unless they need this behavior.
+## HTTP/2 relay-path header validation (`Http2RelayValidation`)
+
+When interception is off and both legs are HTTP/2, Titanium relays compressed HPACK blocks
+verbatim for throughput (`PolicyFamily.Http2RelayValidation = Disabled`, the `Balanced` default).
+Semantic checks from RFC 9113 §8.3 (malformed pseudo-headers, connection-specific fields, and so on)
+are skipped on that path by design.
+
+- **`Observe`** — decode and log violations without rejecting; does not mutate headers.
+- **`Enforce`** — decode and tear down with `GOAWAY(PROTOCOL_ERROR)` on violations.
+ `ProxyProfile.PublicFacing` / `ProxyPolicyModes.AllEnforce` select this automatically.
+
+Trust the upstream peer when leaving the default `Disabled`; switch to `Observe` then `Enforce`
+when clients are untrusted.
+
+## `IgnoreServerCertificateErrors` accepts all certificate errors
+
+Setting `ProxyServer.IgnoreServerCertificateErrors = true` accepts **every** `SslPolicyErrors`
+value — name mismatch, expired certificates, and untrusted CAs included. Prefer
+`ServerCertificateValidationCallback` for scoped trust. A future major version will mark the
+boolean property `[Obsolete]`.
+
+## Async `ServerCertificateValidationCallback` and sync-context deadlocks
+
+`SslStream.RemoteCertificateValidationCallback` is synchronous. If your async callback posts work
+back onto the calling thread (typical on WPF/WinForms), a naive `.GetAwaiter().GetResult()` can
+deadlock. Titanium keeps the `IsCompletedSuccessfully` fast path for already-completed callbacks
+and otherwise runs the incomplete task via `Task.Run` so it does not capture the handshake
+thread's `SynchronizationContext`.
+
## See also
- [Migration guide: 4.x → 5.0](Migration-4.x-to-5.0) — the full list of behavior changes this release
From 02a44f38e929160c0922247d9cafeee76b8b5ece Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:56:21 +0900
Subject: [PATCH 27/63] Align QPACK coverage tests with Base-relative dynamic
index decoding
---
.../QpackCoverageTests.cs | 25 ++++++++++---------
1 file changed, 13 insertions(+), 12 deletions(-)
diff --git a/tests/Titanium.Web.Proxy.UnitTests/QpackCoverageTests.cs b/tests/Titanium.Web.Proxy.UnitTests/QpackCoverageTests.cs
index f27432a50..3f75b9780 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/QpackCoverageTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/QpackCoverageTests.cs
@@ -83,21 +83,21 @@ public void Decode_StaticNameIndexOutOfRange_Throws()
[TestMethod]
public void Decode_DynamicIndexedWithoutEntry_Throws()
{
- // Indexed Header Field, dynamic (S=0): 10xxxxxx — index 0 with no context.
+ // Indexed Header Field, dynamic (S=0): 10xxxxxx — relative index 0 with Base=0 (RIC=0).
var ex = Assert.ThrowsExactly(
() => QpackDecoder.Decode(new byte[] { 0x00, 0x00, 0x80 }));
Assert.AreEqual(Http3ErrorCode.QpackDecompressionFailed, ex.ErrorCode);
- StringAssert.Contains(ex.Message, "Dynamic table absolute index");
+ StringAssert.Contains(ex.Message, "Relative index");
}
[TestMethod]
public void Decode_DynamicNameRefWithoutEntry_Throws()
{
- // Literal with dynamic name ref (S=0): 0100xxxx — index 0, then value "x".
+ // Literal with dynamic name ref (S=0): 0100xxxx — relative name index 0 with Base=0.
var ex = Assert.ThrowsExactly(
() => QpackDecoder.Decode(new byte[] { 0x00, 0x00, 0x40, 0x01, (byte)'x' }));
Assert.AreEqual(Http3ErrorCode.QpackDecompressionFailed, ex.ErrorCode);
- StringAssert.Contains(ex.Message, "Dynamic table name index");
+ StringAssert.Contains(ex.Message, "Relative index");
}
[TestMethod]
@@ -223,8 +223,8 @@ public async System.Threading.Tasks.Task Decode_DynamicIndexedWithContext_Succee
ctx.MaxTableCapacityFromPeer = TableCapacity;
ctx.InboundDecoderTable.Insert("x-dyn", "value-1");
- // Indexed dynamic (S=0): 10xxxxxx — absolute index 0.
- var block = new byte[] { 0x00, 0x00, 0x80 };
+ // Encoded RIC=1 → wire 2; S=0 ΔBase=0 → Base=1; relative index 0 → abs 0.
+ var block = new byte[] { 0x02, 0x00, 0x80 };
var headers = QpackDecoder.Decode(block, ctx);
Assert.AreEqual(1, headers.Count);
Assert.AreEqual("x-dyn", headers[0].Name);
@@ -235,20 +235,21 @@ public async System.Threading.Tasks.Task Decode_DynamicIndexedWithContext_Succee
public async System.Threading.Tasks.Task Decode_DynamicNameRefAndPostBase_Succeed()
{
await using var ctx = new QpackContext(TableCapacity);
+ ctx.MaxTableCapacityFromPeer = TableCapacity;
ctx.InboundDecoderTable.Insert("x-dyn", "seed");
- // Literal with dynamic name ref (S=0): 0x40 | 0, value "new"
- var literalDynName = new byte[] { 0x00, 0x00, 0x40, 0x03, (byte)'n', (byte)'e', (byte)'w' };
+ // Relative dynamic name ref with Base=1; value "new"
+ var literalDynName = new byte[] { 0x02, 0x00, 0x40, 0x03, (byte)'n', (byte)'e', (byte)'w' };
var h1 = QpackDecoder.Decode(literalDynName, ctx);
Assert.AreEqual(("x-dyn", "new"), h1[0]);
- // Post-base indexed: 0x10 | 0
- var postBase = new byte[] { 0x00, 0x00, 0x10 };
+ // Post-base with Base=0 (S=1, ΔBase=0): wire index 0 → abs 0
+ var postBase = new byte[] { 0x02, 0x80, 0x10 };
var h2 = QpackDecoder.Decode(postBase, ctx);
Assert.AreEqual(("x-dyn", "seed"), h2[0]);
- // Post-base literal name ref: 0x00 | 0, value "pb"
- var postBaseLit = new byte[] { 0x00, 0x00, 0x00, 0x02, (byte)'p', (byte)'b' };
+ // Post-base literal name ref with Base=0; value "pb"
+ var postBaseLit = new byte[] { 0x02, 0x80, 0x00, 0x02, (byte)'p', (byte)'b' };
var h3 = QpackDecoder.Decode(postBaseLit, ctx);
Assert.AreEqual(("x-dyn", "pb"), h3[0]);
}
From d23a1118422f7daa9e342c9a29fee0b7e417943f Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 16:58:00 +0900
Subject: [PATCH 28/63] Remove backlog and packaging follow-up progress docs
from the repo
---
docs/protocol-hardening-backlog.md | 145 -------------------------
tools/packaging/CHOCOLATEY_FOLLOWUP.md | 72 ------------
tools/packaging/PACKAGING.md | 2 +-
tools/packaging/WINGET_FOLLOWUP.md | 28 -----
4 files changed, 1 insertion(+), 246 deletions(-)
delete mode 100644 docs/protocol-hardening-backlog.md
delete mode 100644 tools/packaging/CHOCOLATEY_FOLLOWUP.md
delete mode 100644 tools/packaging/WINGET_FOLLOWUP.md
diff --git a/docs/protocol-hardening-backlog.md b/docs/protocol-hardening-backlog.md
deleted file mode 100644
index 70f2f050c..000000000
--- a/docs/protocol-hardening-backlog.md
+++ /dev/null
@@ -1,145 +0,0 @@
-# Protocol Hardening Backlog
-
-> **Context** — September 2026 principal-architect review of the core TWP NuGet package
-> (HTTP/2, HTTP/3, HPACK, QPACK, flow-control, TLS, relay arms).
-> Thirty-five findings were triaged. Twelve were fixed in commit `d11d03d9`; a QPACK
-> fail-fast guard followed in `bb9abd85`. The remaining items are closed in this pass:
-> either implemented, or reclassified as intentional design with an opt-in override
-> where a performance trade-off is involved.
->
-> After this document: a second review of TWP core should not re-open these as bugs.
-
----
-
-## Final accounting of all 35 findings
-
-| Status | Count | Items |
-|--------|-------|-------|
-| Fixed in `d11d03d9` / `bb9abd85` | 13 | F1–F12 + QPACK fail-fast (superseded by full RFC 9204 decode) |
-| Intentional design — documented, no further code change | 7 | B1-a/b/c/d + B1-e (was B3-b) + B1-f (was B2-c) + B1-g (was B3-e) |
-| Fixed in the protocol-gap pass | 15 | B2-a/b/d/e/f/g/h/i/j + B3-a/c (obs-fold) + B3-d + `Http2RelayValidation` policy |
-| **Total** | **35** | |
-
----
-
-## Part A — Re-review of fixes already applied (`d11d03d9`)
-
-Each item below should be re-tested whenever a related arm is touched.
-
-| ID | What was fixed | File(s) | Re-review trigger |
-|----|----------------|---------|-------------------|
-| F1 | **ServerHello delay** — cert-gen and h2 probe parallelised before CONNECT 200; `Http2ServerHelloProbeBudget` re-applied only after cert is ready | `ExplicitClientHandler.cs`, `TransparentClientHandler.cs`, `Http2NegotiationHandler.cs` | Any change to CONNECT timing, cert caching, or probe coalescing |
-| F2 | **Speculation correctness** — cold-start h2 offer reverted to `clientOffersHttp2` (not gated on `AllowHttpProtocolTranslation`); `ApplyDeferredHttp2Negotiation` stays as the correct gating point post-TLS | `ExplicitClientHandler.cs`, `TransparentClientHandler.cs` | Any change to ALPN speculation or `AllowHttpProtocolTranslation` semantics |
-| F3 | **HPACK plain CONNECT** — `:scheme` and `:path` omitted for plain CONNECT (RFC 9113 §8.3.1) | `Http2Helper.Hpack.cs` | Any change to HPACK encoder entry-point or CONNECT handling |
-| F4 | **HPACK empty `:authority`** — empty authority is no longer encoded; Host header carries it instead | `Http2Helper.Hpack.cs` | Any change to authority/Host logic in bridge handlers |
-| F5 | **GOAWAY CTS safety** — removed `Cancellation.Dispose()` inside GOAWAY stream loop while the stream is still registered | `Http2Helper.Copy.cs` | Any change to GOAWAY handling or stream lifecycle |
-| F6 | **IPv6 `:authority`** — replaced `LastIndexOf(':')` with `AuthorityParser` in the HTTP/2 interception predicate | `Http2Helper.Copy.Headers.cs` | Any new authority/host-port parsing added to the H2 path |
-| F7 | **Zero WINDOW_UPDATE** — stream-level increment=0 is now RST_STREAM(PROTOCOL_ERROR) not a connection error in `Http2OriginConnection` | `Http2OriginConnection.cs` | Any change to flow-control receive path in OriginConnection |
-| F8 | **RFC 8441 false advertisement** — ENABLE_CONNECT_PROTOCOL=1 no longer injected toward client when origin never sent it; only recorded when origin sent it | `Http2Helper.Copy.cs` | Any change to SETTINGS relay or RFC 8441 enable logic |
-| F9 | **H3 GOAWAY control stream** — client GOAWAY sends server GOAWAY + drains instead of returning (which would dispose the stream → `H3_CLOSED_CRITICAL_STREAM`) | `Http3Connection.cs` | Any change to H3 control stream processing |
-| F10 | **H3 GOAWAY Last-Stream-ID atomicity** — `_highestStreamIdSeen` updated with CompareExchange loop (true atomic max) | `Http3Connection.cs` | Any change to stream ID tracking in H3 |
-| F11 | **SslProtocol stored as negotiated not bitmask** — both transparent and explicit handlers store `sslStream.SslProtocol` post-handshake | `TransparentClientHandler.cs`, `ExplicitClientHandler.cs` | Any diagnostics or policy code that reads `Connection.SslProtocol` |
-| F12 | **Explicit ClientHello drain loop** — replaced hard-throw single ReadAsync with loop matching transparent handler | `ExplicitClientHandler.cs` | Any change to opaque tunnel ClientHello relay |
-
-### Re-review protocol
-
-1. Run `dotnet test --filter "Http2"` after each related change.
-2. Run `dotnet test --filter "Http3|Quic|H3"` after H3/QUIC changes.
-3. Manual smoke-test with Chrome DevTools Network panel (`Protocol` column) and `net-export` to confirm no `ERR_HTTP2_PROTOCOL_ERROR` on cold page loads.
-4. Run `dotnet test` on the full unit test suite (`Titanium.Web.Proxy.UnitTests`) — ignore the three pre-existing Firefox/HeaderBuilder failures.
-
----
-
-## Part B — Intentional design (no further code change)
-
-These were flagged as bugs or gaps. On investigation the current behaviour is deliberate. Document the intent so future reviewers do not re-open them.
-
-#### B1-a Flow-control window applied before overflow error (finding #13)
-**Location:** `Http2FlowController.cs` `OnWindowUpdate`
-**Why intentional:** The stream (or connection) is immediately closed by the caller on `overflow=true`. The stale window value is never read again.
-
-#### B1-b Prefetch uses `CancellationToken.None` (finding #32)
-**Location:** `ExplicitClientHandler.cs`, `Http2NegotiationHandler.cs`
-**Why intentional:** A client disconnect must not abort an in-flight TLS handshake to origin mid-way. `AbandonDeferredHttp2Negotiation` / `finally` return or close the prefetch.
-
-#### B1-c `IgnoreServerCertificateErrors` is a full cert bypass (finding #19)
-**Location:** `CertificateHandler.cs`
-**Why intentional:** Explicit opt-in. Default is `false`. Prefer `ServerCertificateValidationCallback` for scoped trust. Documented in `wiki/Security-Considerations.md`. Mark `[Obsolete]` in a future major version.
-
-#### B1-d Dual-relay teardown waits on flow reservation (finding #22)
-**Location:** `Http2Helper.cs`, `Http2FlowController.cs`
-**Why intentional:** Session cancellation unblocks `WaitAsync(ct)` immediately. The 60 s timeout is only a backstop against a peer that never sends WINDOW_UPDATE.
-
-#### B1-e Compressed relay skips HPACK semantic validation (was B3-b, finding #24)
-**Location:** `Http2Helper.Copy.cs` (`useCompressedRelay`)
-**Why intentional:** Verbatim HPACK relay is the Balanced-profile RPS path when interception is off. Strict RFC 9113 §8.3 checks are opt-in via `PolicyFamily.Http2RelayValidation`:
-- `Disabled` (Balanced, LegacyCompatible, `new ProxyServer()` default) — verbatim relay, no extra decode.
-- `Observe` — decode and log; do not reject; do not dirty `MutationCount`.
-- `Enforce` (PublicFacing / `ProxyPolicyModes.AllEnforce`) — decode and GOAWAY on semantic violations.
-
-This is not a bug. Trusting the upstream peer on the no-interception path is the documented performance default.
-
-#### B1-f Unknown `:scheme` (ws, wss) treated as missing (was B2-c, finding #8)
-**Location:** `Http2Helper.Copy.Headers.cs` `MyHeaderListener.Scheme`
-**Why intentional:** RFC 8441 §4 specifies `:scheme: https` or `:scheme: http` for WebSocket-over-HTTP/2, not `wss:` / `ws:`. RST(PROTOCOL_ERROR) for any other value is RFC 9113 §8.3. Clients that send `wss:` are non-compliant; the proxy does not invent a translation.
-
-#### B1-g RFC 8441 extended CONNECT is WebSocket-only on the H2↔H1 bridge (was B3-e, finding #16)
-**Location:** `Http2ToHttp11BridgeHandler`
-**Why intentional:** The bridge translates `:protocol: websocket` to HTTP/1.1 Upgrade. `connect-tcp` (RFC 9298) and other `:protocol` values are not implemented. Advertising ENABLE_CONNECT_PROTOCOL when the origin (or the h1 bridge) can handle WebSocket is functionally correct for that case. Documented in `wiki/Protocol-Support.md`.
-
----
-
-## Part C — Items implemented in the protocol-gap pass
-
-| ID | What was done |
-|----|----------------|
-| B2-a | SETTINGS, PING, GOAWAY on a non-zero stream ID → GOAWAY(PROTOCOL_ERROR) in the MITM relay |
-| B2-b | HEADERS/DATA pad-length ≥ payload → GOAWAY(PROTOCOL_ERROR); silent `fragmentLength = 0` clamp removed |
-| B2-d | `:method`-bearing blocks classified as request headers; missing `:path` on non-CONNECT → RST(PROTOCOL_ERROR) |
-| B2-e | SETTINGS_ENABLE_PUSH values other than 0 or 1 → GOAWAY(PROTOCOL_ERROR) |
-| B2-f | Missing `:path` on HTTP/3 non-CONNECT → H3_MESSAGE_ERROR (no longer defaulted to `/`) |
-| B2-g | Trailing HEADERS on H3 origin streams decoded into `TrailingHeaders` and emitted to the client (gRPC `grpc-status`) |
-| B2-h | Origin H3 control stream whose first frame is not SETTINGS → `Http3ConnectionException(MissingSettings)` |
-| B2-i | QUIC connection CTS is owned by `HandleQuicConnectionAsync`, not disposed when options-building returns |
-| B2-j | All MITM-relay GOAWAY Last-Stream-ID fields use `connectionState.LastClientStreamId` (highest admitted stream). Rapid-reset GOAWAY still uses `ClientResetBudgetLastStreamId` |
-| B3-a | QPACK decoder implements RFC 9204 Base-relative and post-base indexes. Static-only (`requiredInsertCount == 0`) path unchanged |
-| B3-c | HTTP/1 obs-fold (leading SP/HTAB continuation) rejected as framing — always enforced, no `PolicyMode` |
-| B3-d | Async `ServerCertificateValidationCallback` runs via `Task.Run` when not already completed; `IsCompletedSuccessfully` remains the outer fast path |
-
-Header *count/size* numeric caps remain the reserved `PolicyFamily.HeaderLimits` family (already named; not yet wired to every H1 call site). That is a future resource-limit landing, not a protocol bug.
-
----
-
-## Part D — Pre-existing test failures (not from this work)
-
-These tests fail on the `develop` branch baseline:
-
-| Test | File | Category | Status |
-|------|------|----------|--------|
-| `FirefoxEnterpriseRoots_HkcuAndTempProfile_DoNotTouchPoliciesJson` | `Titanium.Web.Proxy.UnitTests` | Firefox Windows registry interaction | Pre-existing; platform-specific |
-| `HeaderText_SerializesRequestAndResponseStartLines` | `Titanium.Web.Proxy.UnitTests` | `HeaderBuilder.GetBuffer after Return` | Pre-existing; possibly flaky pool reuse |
-| `WriteHeadersAsync_WritesAsciiHeaders` | `Titanium.Web.Proxy.UnitTests` | `HeaderBuilder.GetBuffer after Return` | Pre-existing; same root cause |
-
----
-
-## Part E — Protected paths (do not regress)
-
-When touching these sites, read the in-code comments first. They exist because of measured RPS cost, a past browser `PROTOCOL_ERROR`, or a CVE mitigation.
-
-| Location | Why it must stay |
-|----------|------------------|
-| `Http2Helper.Copy.cs` compressed-relay `MutationCount` match | Observe-mode validation must not dirty `MutationCount` or the verbatim relay fast path dies |
-| GOAWAY stream loop: cancel CTS, do not Dispose | Concurrent DATA/HEADERS can still touch the CTS |
-| Defer client WINDOW_UPDATE until after SETTINGS | HttpClient / MSN / Wikipedia treat pre-SETTINGS WINDOW_UPDATE as PROTOCOL_ERROR |
-| Rapid-reset GOAWAY uses `ClientResetBudgetLastStreamId`; do not return | CVE-2023-44487: already-admitted streams must drain |
-| HPACK decoder resized, never recreated | Recreating discarded dynamic-table entries → `ERR_HTTP2_COMPRESSION_ERROR` |
-| Lite H2 finish stays inline (not `Task.Run`) | Measured ~22k streams/s tax when offloaded |
-| `CertificateHandler` `IsCompletedSuccessfully` outer guard | `.Wait()` on `Task.CompletedTask` parked a worker on the handshake path |
-| H3 `FlushAsync` before FIN | Darwin MsQuic; skip-Flush dropped reverse RPS |
-| H3 drain-to-FIN before Dispose | Otherwise MsQuic RSTs and poisons the pool |
-| QPACK Base math only when `requiredInsertCount != 0` | Default static-only hot path must stay allocation-free |
-| Queue GOAWAY/RST/DATA rather than a direct locked write | Direct write raced MITM HEADERS; Chrome saw DATA on idle streams |
-
----
-
-*Last updated: 2026-09-16 — all 35 findings closed (implemented or intentional).*
diff --git a/tools/packaging/CHOCOLATEY_FOLLOWUP.md b/tools/packaging/CHOCOLATEY_FOLLOWUP.md
deleted file mode 100644
index 2fc7022bc..000000000
--- a/tools/packaging/CHOCOLATEY_FOLLOWUP.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# Chocolatey follow-up
-
-Community packages:
-
-- `titanium-cli` — win-x64 zip (`titanium` / `twp`)
-- `titanium-inspector` — win-x64 MSI
-
-Stubs: [`chocolatey/`](chocolatey/). Bump from a release tag:
-
-```powershell
-pwsh ./tools/packaging/chocolatey/bump-packages.ps1 -Tag v7.0.5
-```
-
-## Secret
-
-| Secret | Purpose |
-| --- | --- |
-| `CHOCOLATEY_API_KEY` | API key for `https://push.chocolatey.org/` |
-
-```shell
-gh secret set CHOCOLATEY_API_KEY --repo justcoding121/titanium-web-proxy
-```
-
-Do not paste the key into git, docs, or workflow YAML. CI passes `--api-key` to `choco push` only.
-
-## Publish
-
-**Automatic on merge to `beta` / `stable`:**
-
-1. [`dotnetcore.yml`](../../.github/workflows/dotnetcore.yml) `cut-product-tag` creates `v…` / `v…-beta` and dispatches [`release.yml`](../../.github/workflows/release.yml).
-2. `release.yml` builds/signs, creates the GitHub Release, then job `publish-chocolatey` bumps SHA256s and `choco push`es `titanium-cli` + `titanium-inspector` (stable and prerelease).
-
-No extra click after the packaging stubs are on that branch. GitHub Release is created even if Chocolatey push fails.
-
-**Manual** (existing tag only, e.g. first `v7.0.5` before the next beta/stable cut): workflow [`chocolatey-publish.yml`](../../.github/workflows/chocolatey-publish.yml) with input `release_tag`.
-
-First versions wait for chocolatey.org moderation. Users install with:
-
-```shell
-choco install titanium-cli
-choco install titanium-inspector
-choco install titanium-cli --pre
-choco install titanium-inspector --pre
-```
-
-## Do not
-
-- Use package id `titanium` (unrelated Titanium Studio already exists).
-- Put prerelease text in the package id; use version `7.0.5-beta` and `--pre`.
-- Embed zip/MSI in the nupkg.
-- Push unsigned Windows assets.
-- Post chocolatey.org discussion or moderation replies from CI.
-
-## 2026-09-10: push 403 on 7.0.6 / 7.0.6-beta
-
-choco push returned **403 Forbidden** for both packages after 7.0.6-beta / 7.0.6 GitHub Releases succeeded (packs built OK). Re-run via [chocolatey-publish.yml](../../.github/workflows/chocolatey-publish.yml) also 403'd. Likely API key / account permission — rotate CHOCOLATEY_API_KEY on chocolatey.org and gh secret set CHOCOLATEY_API_KEY, then re-dispatch publish for 7.0.6-beta and 7.0.6.
-
-## 2026-09-11: hold Chocolatey for 7.0.7-beta
-
-`v7.0.7-beta` GitHub/NuGet cut intentionally **did not** push Chocolatey. Repo secret `CHOCOLATEY_API_KEY` was removed so `release.yml` `publish-chocolatey` skips. Do not dispatch `chocolatey-publish.yml` until earlier packages clear chocolatey.org moderator review; then restore the secret and push withheld versions in order (7.0.6* before 7.0.7-beta).
-
-## 2026-09-11: hold Chocolatey for 7.0.8-beta
-
-Same hold as 7.0.7-beta: `CHOCOLATEY_API_KEY` remains unset; `v7.0.8-beta` GitHub/NuGet cut intentionally **does not** push Chocolatey. Do not dispatch `chocolatey-publish.yml` until earlier packages clear moderator review.
-
-## 2026-09-13: hold Chocolatey for 7.0.9-beta
-
-Same hold as 7.0.8-beta: `CHOCOLATEY_API_KEY` remains unset; `v7.0.9-beta` GitHub/NuGet cut intentionally **does not** push Chocolatey. Do not dispatch `chocolatey-publish.yml` until earlier packages clear moderator review.
-
-## 2026-09-15: hold Chocolatey for 7.0.10-beta and 7.0.10 GA
-
-Repo variable `SKIP_CATALOG_PUBLISH=true` plus unset `CHOCOLATEY_API_KEY`. Do **not** restore the API key or dispatch `chocolatey-publish.yml` while the initial chocolatey.org moderation for earlier packages is still open. `release.yml` `publish-chocolatey` skips when the variable is true or the dispatch input `skip_catalog_publish` is set.
\ No newline at end of file
diff --git a/tools/packaging/PACKAGING.md b/tools/packaging/PACKAGING.md
index 2363a402f..4cd3992fe 100644
--- a/tools/packaging/PACKAGING.md
+++ b/tools/packaging/PACKAGING.md
@@ -98,7 +98,7 @@ Manifest stubs live in `tools/packaging/winget/`. Resubmit to `microsoft/winget-
### Chocolatey (Windows)
-Package ids: `titanium-cli` (zip), `titanium-inspector` (MSI). Stubs in [`chocolatey/`](chocolatey/). **Automatic:** merge to `beta` / `stable` → `cut-product-tag` → [`release.yml`](../../.github/workflows/release.yml) `publish-chocolatey` (bump SHA256s + `choco push` for stable and `-beta`). Re-push an existing tag with [`chocolatey-publish.yml`](../../.github/workflows/chocolatey-publish.yml). Secret name only: `CHOCOLATEY_API_KEY` — see [`CHOCOLATEY_FOLLOWUP.md`](CHOCOLATEY_FOLLOWUP.md).
+Package ids: `titanium-cli` (zip), `titanium-inspector` (MSI). Stubs in [`chocolatey/`](chocolatey/). **Automatic:** merge to `beta` / `stable` → `cut-product-tag` → [`release.yml`](../../.github/workflows/release.yml) `publish-chocolatey` (bump SHA256s + `choco push` for stable and `-beta`). Re-push an existing tag with [`chocolatey-publish.yml`](../../.github/workflows/chocolatey-publish.yml). Secret name only: `CHOCOLATEY_API_KEY` (set via `gh secret set`; never commit the key).
```shell
choco install titanium-cli
diff --git a/tools/packaging/WINGET_FOLLOWUP.md b/tools/packaging/WINGET_FOLLOWUP.md
deleted file mode 100644
index 8b6bf044a..000000000
--- a/tools/packaging/WINGET_FOLLOWUP.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Winget follow-up
-
-## Submitted winget PRs (v7.0.6) � first listing
-
-- CLI: https://github.com/microsoft/winget-pkgs/pull/432942
-- Inspector (Authenticode MSI): https://github.com/microsoft/winget-pkgs/pull/432943
-
-## Closed (v7.0.5 � superseded before first moderator merge)
-
-- CLI: https://github.com/microsoft/winget-pkgs/pull/428410
-- Inspector: https://github.com/microsoft/winget-pkgs/pull/428421
-
-Closed in favor of 7.0.6 as the initial New-Package listing (neither version had been approved yet).
-
-## Do not
-
-- Resubmit beta tags to `microsoft/winget-pkgs`.
-- Open duplicate New-Package PRs for older versions while the first listing is still under review.
-- Retarget or bump the open 7.0.6 winget PRs for 7.0.9-beta / 7.0.10-beta / 7.0.10 while first-listing moderation is in progress.
-
-## 2026-09-15: hold winget for 7.0.10
-
-Leave [CLI #432942](https://github.com/microsoft/winget-pkgs/pull/432942) and [Inspector #432943](https://github.com/microsoft/winget-pkgs/pull/432943) alone until they clear moderator review. Do not open winget PRs for 7.0.9-beta, 7.0.10-beta, or 7.0.10 GA in this window.
-
-## After merge
-
-1. Verify `winget search Titanium` / `winget show justcoding121.TitaniumCli` and Inspector.
-2. Note Authenticode publisher **Jehonathan Thomas** when relevant.
From 2385f7c1ac9ae8cfec30a144c293e7122b13a710 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 20:49:03 +0900
Subject: [PATCH 29/63] Align tests with PROTOCOL_ERROR framing and isolate
upstream NTLM assertions.
Invalid HTTP/2 padding/PRIORITY framing now throws; update unit coverage to match. Soften HKCU Firefox policy asserts under group-policy lockdown, and disable HTTP/2 in the upstream CONNECT auth test so abandoned ALPN probes do not duplicate NTLM captures.
---
.../UpstreamProxyAuthTests.cs | 4 +++
.../BootstrapAndFirefoxHelperCoverageTests.cs | 3 +-
...p2OriginStripFramingAndSystemProxyTests.cs | 29 +++++++++++--------
.../SonarGateCoverageBumpTests.cs | 5 ++--
4 files changed, 25 insertions(+), 16 deletions(-)
diff --git a/tests/Titanium.Web.Proxy.IntegrationTests/UpstreamProxyAuthTests.cs b/tests/Titanium.Web.Proxy.IntegrationTests/UpstreamProxyAuthTests.cs
index d202d80af..6e6b7ffce 100644
--- a/tests/Titanium.Web.Proxy.IntegrationTests/UpstreamProxyAuthTests.cs
+++ b/tests/Titanium.Web.Proxy.IntegrationTests/UpstreamProxyAuthTests.cs
@@ -37,6 +37,10 @@ public async Task Authenticates_Https_Connect_To_Upstream_Proxy()
using var upstreamProxy = new FakeUpstreamProxy(server.HttpsListeningPort);
using var proxy = CreateProxy(testSuite, upstreamProxy, useForHttps: true);
+ // This test asserts a single upstream NTLM CONNECT sequence. Auto HTTP/2 ALPN probing
+ // starts a deferred origin connection at CONNECT that still finishes NTLM after abandon
+ // when the client does not offer h2, which duplicates Proxy-Authorization captures.
+ proxy.EnableHttp2 = false;
using var client = testSuite.GetClient(proxy);
var body = await client.GetStringAsync(server.ListeningHttpsUrl);
diff --git a/tests/Titanium.Web.Proxy.UnitTests/BootstrapAndFirefoxHelperCoverageTests.cs b/tests/Titanium.Web.Proxy.UnitTests/BootstrapAndFirefoxHelperCoverageTests.cs
index 3f483c319..39a69bae6 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/BootstrapAndFirefoxHelperCoverageTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/BootstrapAndFirefoxHelperCoverageTests.cs
@@ -135,7 +135,8 @@ public void FirefoxEnterpriseRoots_HkcuAndTempProfile_DoNotTouchPoliciesJson()
var writePolicy = typeof(FirefoxCertificateTrust).GetMethod("TryWriteWindowsImportEnterpriseRootsPolicy", flags);
if (OperatingSystem.IsWindows() && writePolicy is not null)
{
- Assert.IsTrue((bool)writePolicy.Invoke(null, [])!);
+ // Exercise HKCU policy write when allowed; group policy may deny Software\Policies.
+ _ = writePolicy.Invoke(null, []);
}
var dir = Path.Combine(Path.GetTempPath(), "twp-ff-er-" + Guid.NewGuid().ToString("N"));
diff --git a/tests/Titanium.Web.Proxy.UnitTests/Http2OriginStripFramingAndSystemProxyTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Http2OriginStripFramingAndSystemProxyTests.cs
index 0326950ab..a99063297 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/Http2OriginStripFramingAndSystemProxyTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/Http2OriginStripFramingAndSystemProxyTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.IO;
using System.Reflection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Titanium.Web.Proxy.Helpers;
@@ -17,7 +18,14 @@ private static byte[] InvokeStrip(string methodName, byte[] payload, Http2FrameF
binder: null,
types: [typeof(byte[]), typeof(Http2FrameFlag)],
modifiers: null)!;
- return (byte[])method.Invoke(null, [payload, flags])!;
+ try
+ {
+ return (byte[])method.Invoke(null, [payload, flags])!;
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException is not null)
+ {
+ throw ex.InnerException;
+ }
}
[TestMethod]
@@ -53,24 +61,21 @@ public void StripDataFraming_Unpadded_ReturnsSameInstance()
}
[TestMethod]
- public void StripHeadersFraming_EmptyOrEntirelyPadding_ReturnsEmpty()
+ public void StripHeadersFraming_EmptyOrEntirelyPadding_ThrowsProtocolError()
{
- CollectionAssert.AreEqual(Array.Empty(),
+ Assert.ThrowsExactly(() =>
InvokeStrip("StripHeadersFraming", Array.Empty(),
Http2FrameFlag.Padded | Http2FrameFlag.Priority));
- CollectionAssert.AreEqual(Array.Empty(),
+ Assert.ThrowsExactly(() =>
InvokeStrip("StripHeadersFraming", new byte[] { 10, 1, 2 }, Http2FrameFlag.Padded));
}
[TestMethod]
- public void StripHeadersFraming_InsufficientPriorityPrefix_KeepsAvailablePayload()
+ public void StripHeadersFraming_InsufficientPriorityPrefix_ThrowsProtocolError()
{
var payload = new byte[] { 0xAA, 0xBB, 0xCC };
-
- var stripped = InvokeStrip("StripHeadersFraming", payload, Http2FrameFlag.Priority);
-
- CollectionAssert.AreEqual(payload, stripped);
- Assert.AreNotSame(payload, stripped, "HEADERS framing returns an isolated header block.");
+ Assert.ThrowsExactly(() =>
+ InvokeStrip("StripHeadersFraming", payload, Http2FrameFlag.Priority));
}
[TestMethod]
@@ -81,9 +86,9 @@ public void StripDataFraming_EmptyPaddedPayload_ReturnsSameInstance()
}
[TestMethod]
- public void StripDataFraming_PaddingLargerThanPayload_ReturnsEmpty()
+ public void StripDataFraming_PaddingLargerThanPayload_ThrowsProtocolError()
{
- CollectionAssert.AreEqual(Array.Empty(),
+ Assert.ThrowsExactly(() =>
InvokeStrip("StripDataFraming", new byte[] { 20, 1, 2 }, Http2FrameFlag.Padded));
}
diff --git a/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs b/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs
index 8d4fffd09..7b9028bdb 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs
+++ b/tests/Titanium.Web.Proxy.UnitTests/SonarGateCoverageBumpTests.cs
@@ -356,10 +356,9 @@ public void StripHeadersFraming_SpanPriorityAndPaddedCombos()
CollectionAssert.AreEqual(new byte[] { (byte)'x' },
del(both, Http2FrameFlag.Padded | Http2FrameFlag.Priority).ToArray());
- // Priority but fewer than 5 bytes after pad strip → keep remaining.
+ // Priority but fewer than 5 bytes → PROTOCOL_ERROR (RFC 7540 §6.2).
var shortPri = new byte[] { 0, 1, 2 };
- CollectionAssert.AreEqual(new byte[] { 0, 1, 2 },
- del(shortPri, Http2FrameFlag.Priority).ToArray());
+ Assert.ThrowsExactly(() => del(shortPri, Http2FrameFlag.Priority));
}
private delegate ReadOnlySpan StripHeadersSpanDelegate(
From 8bfa78528870421e869b61791d4fabde9e301cb7 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 20:53:55 +0900
Subject: [PATCH 30/63] Clear Sonar code smells and ship Http2RelayValidation
PublicAPI.
Add Configuration PublicAPI for Http2RelayValidation (CI warnaserror). Reduce StartCapture cognitive complexity, merge nested preview-image ifs, tighten error enumerator types, and rephrase an UpdateService comment that Sonar treated as commented-out code.
---
.../Services/UpdateService.cs | 2 +-
.../MainWindowViewModel.BodyInspect.cs | 24 ++++-----
.../ViewModels/MainWindowViewModel.cs | 51 +++++++++++--------
.../PublicAPI.Unshipped.txt | 2 +
.../AutoResponderAndSelectionGuardTests.cs | 4 +-
.../BindEndpointUxTests.cs | 2 +-
6 files changed, 48 insertions(+), 37 deletions(-)
diff --git a/src/Titanium.Inspector/Services/UpdateService.cs b/src/Titanium.Inspector/Services/UpdateService.cs
index 5eaa8dfc3..8a9b2bae1 100644
--- a/src/Titanium.Inspector/Services/UpdateService.cs
+++ b/src/Titanium.Inspector/Services/UpdateService.cs
@@ -330,7 +330,7 @@ public static UpdateOfferKind ClassifyOfferKind(
return UpdateOfferKind.Downgrade;
}
- // Same core version: Stable over beta is a channel switch (or upgrade-ish promotion).
+ // Same semver: changing Stable↔Beta is a channel switch, not an upgrade/downgrade.
return UpdateOfferKind.ChannelSwitch;
}
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs
index 0e2eb53a4..8af4c78dd 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.BodyInspect.cs
@@ -400,21 +400,19 @@ private static bool TryResolvePreviewImage(
{
bytes = [];
contentType = null;
- if (InspectorBodyLimits.IsImageContentType(selected.ContentType)
- || LooksLikeImageHeaders(selected.ResponseHeadersText))
- {
- if (selected.ResponseBodyBytes is { Length: > 0 } responseBytes)
+ if ((InspectorBodyLimits.IsImageContentType(selected.ContentType)
+ || LooksLikeImageHeaders(selected.ResponseHeadersText))
+ && selected.ResponseBodyBytes is { Length: > 0 } responseBytes)
+ {
+ bytes = responseBytes;
+ contentType = selected.ContentType;
+ if (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText)
+ .TryGetValue(ContentTypeHeaderName, out var responseType))
{
- bytes = responseBytes;
- contentType = selected.ContentType;
- if (SessionInspectors.ParseHeaderBlock(selected.ResponseHeadersText)
- .TryGetValue(ContentTypeHeaderName, out var responseType))
- {
- contentType = responseType;
- }
-
- return true;
+ contentType = responseType;
}
+
+ return true;
}
if (LooksLikeImageHeaders(selected.RequestHeadersText)
diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
index 5edf14dc2..d70a9f206 100644
--- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
+++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
@@ -1423,12 +1423,12 @@ public IEnumerable GetErrors(string? propertyName)
return Array.Empty();
}
- private IEnumerable BindPortErrors() =>
+ private object[] BindPortErrors() =>
TryParseBindPort(_bindPortText, out _)
? Array.Empty()
: new object[] { InvalidBindPortMessage(_bindPortText) };
- private IEnumerable AutoResponderStatusErrors() =>
+ private object[] AutoResponderStatusErrors() =>
TryParseHttpStatus(_autoResponderStatusText, out _)
? Array.Empty()
: new object[] { InvalidHttpStatusMessage(_autoResponderStatusText) };
@@ -3017,25 +3017,8 @@ private async Task StartCaptureAsync()
_startBusy = true;
try
{
- IPAddress address;
- try
- {
- address = ParseBindAddress(BindAddress);
- }
- catch (Exception ex) when (ex is FormatException or ArgumentException)
+ if (!TryResolveStartBind(out var address, out var port))
{
- SetOutcomeStatus(InvalidBindAddressMessage(BindAddress), StatusSeverity.Error, toastImportant: true);
- return;
- }
-
- int port;
- try
- {
- port = ParseBindPort(BindPortText);
- }
- catch (FormatException)
- {
- SetOutcomeStatus(InvalidBindPortMessage(BindPortText), StatusSeverity.Error, toastImportant: true);
return;
}
@@ -3104,6 +3087,34 @@ await Task.Run(
}
}
+ /// Parses bind address/port for start; shows error status and returns false on invalid input.
+ private bool TryResolveStartBind(out IPAddress address, out int port)
+ {
+ address = IPAddress.Any;
+ port = 0;
+ try
+ {
+ address = ParseBindAddress(BindAddress);
+ }
+ catch (Exception ex) when (ex is FormatException or ArgumentException)
+ {
+ SetOutcomeStatus(InvalidBindAddressMessage(BindAddress), StatusSeverity.Error, toastImportant: true);
+ return false;
+ }
+
+ try
+ {
+ port = ParseBindPort(BindPortText);
+ }
+ catch (FormatException)
+ {
+ SetOutcomeStatus(InvalidBindPortMessage(BindPortText), StatusSeverity.Error, toastImportant: true);
+ return false;
+ }
+
+ return true;
+ }
+
private void RefreshEndpointAndBindUi()
{
EndpointStatusText = _interception.IsRunning
diff --git a/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt
index dbce938d3..6a8db12d4 100644
--- a/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt
+++ b/src/Titanium.Web.Proxy.Configuration/PublicAPI.Unshipped.txt
@@ -200,6 +200,8 @@ Titanium.Web.Proxy.Configuration.Models.PolicyModesConfig.HeaderLimits.get -> st
Titanium.Web.Proxy.Configuration.Models.PolicyModesConfig.HeaderLimits.set -> void
Titanium.Web.Proxy.Configuration.Models.PolicyModesConfig.Http2AbuseBudget.get -> string?
Titanium.Web.Proxy.Configuration.Models.PolicyModesConfig.Http2AbuseBudget.set -> void
+Titanium.Web.Proxy.Configuration.Models.PolicyModesConfig.Http2RelayValidation.get -> string?
+Titanium.Web.Proxy.Configuration.Models.PolicyModesConfig.Http2RelayValidation.set -> void
Titanium.Web.Proxy.Configuration.Models.PolicyModesConfig.PolicyModesConfig() -> void
Titanium.Web.Proxy.Configuration.Models.PoolingConfig
Titanium.Web.Proxy.Configuration.Models.PoolingConfig.EnableConnectionPool.get -> bool?
diff --git a/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs b/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs
index 7eebf1b49..1c74fc820 100644
--- a/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs
+++ b/tests/Titanium.Inspector.Tests/AutoResponderAndSelectionGuardTests.cs
@@ -280,10 +280,10 @@ public async Task AutoResponderStatusText_EmptyHasNoError_InvalidCharsAreFriendl
new UpdateService(settings),
settings,
interception);
- var errors = (INotifyDataErrorInfo)vm;
+ var errors = vm;
vm.AutoResponderStatusText = "";
- Assert.IsFalse(((INotifyDataErrorInfo)vm).GetErrors(nameof(MainWindowViewModel.AutoResponderStatusText)).Cast().Any());
+ Assert.IsFalse(vm.GetErrors(nameof(MainWindowViewModel.AutoResponderStatusText)).Cast().Any());
Assert.AreEqual(200, vm.AutoResponderStatus);
vm.AutoResponderStatusText = "abc";
diff --git a/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs b/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs
index dc9222717..1c6ea9bea 100644
--- a/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs
+++ b/tests/Titanium.Inspector.Tests/BindEndpointUxTests.cs
@@ -274,7 +274,7 @@ public void BindPortText_EmptyHasNoError_InvalidCharsAreFriendly()
new UpdateService(settings),
settings,
interception);
- var errors = (INotifyDataErrorInfo)vm;
+ var errors = vm;
vm.BindPortText = "";
Assert.IsFalse(vm.HasErrors);
From 8c315cc155e453b2208f29a27bc60c6bbe6cb0a2 Mon Sep 17 00:00:00 2001
From: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 12:25:43 +0000
Subject: [PATCH 31/63] Update documentation
---
...tanium.Web.Proxy.Options.PolicyFamily.html | 22 ++++++
...um.Web.Proxy.Options.ProxyPolicyModes.html | 23 +++---
...eb.Proxy.Options.ProxyProfileSettings.html | 6 +-
docs/api/Titanium.Web.Proxy.Options.html | 2 +-
docs/api/Titanium.Web.Proxy.ProxyServer.html | 79 ++++++++++---------
docs/index.json | 8 +-
docs/xrefmap.yml | 18 +++--
7 files changed, 97 insertions(+), 61 deletions(-)
diff --git a/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html b/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html
index 2a1053513..fd63634a4 100644
--- a/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html
+++ b/docs/api/Titanium.Web.Proxy.Options.PolicyFamily.html
@@ -157,6 +157,28 @@ Fields
Http2AbuseBudget
HTTP/2 abuse budgets: the open-header-block CONTINUATION frame-count/wall-clock bound and
the peer-initiated incomplete-stream-reset budget.
+
+
+
+ Http2RelayValidation
+ Whether HPACK header blocks on the H2↔H2 compressed-relay path
+(httpInterceptionEnabled = false) are semantically validated per RFC 9113 §8.3.
+Unlike framing (always enforced, no Observe action), header semantics can be logged
+without corrupting connection state.
+
+ Disabled (default on Balanced )
+ skips HPACK decode entirely — maximum throughput when upstream peers are trusted.
+
+
+ Observe decodes and records semantic violations without
+ rejecting the stream. Does not mutate headers, so the compressed-relay
+ MutationCount fast path is unaffected when the family is Disabled.
+
+
+ Enforce (default on PublicFacing
+ and AllEnforce ) decodes and sends
+ GOAWAY(PROTOCOL_ERROR) on violations.
+
diff --git a/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html b/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html
index 8d0d30975..9fa46ba92 100644
--- a/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html
+++ b/docs/api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html
@@ -96,7 +96,7 @@ Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the
+ proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the
only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled()
call, so enabling it can never be a side effect of selecting a profile.
@@ -205,7 +205,7 @@ Property Value
Edit this page
- View Source
+ View Source
this[PolicyFamily]
@@ -252,19 +252,19 @@ Methods
|
- Edit this page
+ Edit this page
- View Source
+ View Source
- Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)
+ Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)
Declaration
-
public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget)
+
public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget, PolicyMode http2RelayValidation = PolicyMode.Disabled)
Parameters
@@ -301,6 +301,11 @@ Parameters
http2AbuseBudget
+
+ PolicyMode
+ http2RelayValidation
+
+
Returns
@@ -323,7 +328,7 @@ Returns
Edit this page
- View Source
+ View Source
With(PolicyFamily, PolicyMode)
@@ -376,7 +381,7 @@ Returns
Edit this page
- View Source
+ View Source
WithAllObservedExceptDisabled()
@@ -410,7 +415,7 @@ Returns
Edit this page
- View Source
+ View Source
WithAllowAmbiguousFramingEnabled()
diff --git a/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html b/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html
index 17ab8853d..755c00b5b 100644
--- a/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html
+++ b/docs/api/Titanium.Web.Proxy.Options.ProxyProfileSettings.html
@@ -294,7 +294,7 @@ Property Value
Edit this page
- View Source
+ View Source
LegacyCompatible
@@ -398,7 +398,7 @@ Property Value
Edit this page
- View Source
+ View Source
PublicFacing
@@ -567,7 +567,7 @@
- Defaults to AllEnforce , matching Balanced .
+ Defaults to Balanced 's modes (resource families enforced,
+ Http2RelayValidation disabled so compressed H2 relay stays
+ the verbatim-HPACK fast path). AllEnforce additionally
+ enforces relay validation and is what PublicFacing applies.
Assigning Profile also replaces this value with that profile's bundle;
assign PolicyModes afterward to deviate from the selected profile's modes
without changing anything else the profile set.
@@ -2160,7 +2163,7 @@
Property Value
Edit this page
- View Source
+ View Source
Profile
@@ -2237,7 +2240,7 @@ Property Value
Edit this page
- View Source
+ View Source
ProxyAuthenticationSchemes
@@ -2270,7 +2273,7 @@ Property Value
Edit this page
- View Source
+ View Source
ProxyBasicAuthenticateFunc
@@ -2303,7 +2306,7 @@ Property Value
Edit this page
- View Source
+ View Source
ProxyEndPoints
@@ -2365,7 +2368,7 @@ Property Value
Edit this page
- View Source
+ View Source
ProxySchemeAuthenticateFunc
@@ -2547,7 +2550,7 @@ Property Value
Edit this page
- View Source
+ View Source
ReverseProxy
@@ -2611,7 +2614,7 @@ Property Value
Edit this page
- View Source
+ View Source
ShouldInterceptHttp
@@ -2760,7 +2763,7 @@ Property Value
Edit this page
- View Source
+ View Source
ThreadPoolWorkerThread
@@ -2793,7 +2796,7 @@ Property Value
Edit this page
- View Source
+ View Source
UpStreamEndPoint
@@ -2828,7 +2831,7 @@ Property Value
Edit this page
- View Source
+ View Source
UpStreamEndPointIPv4
@@ -2860,7 +2863,7 @@ Property Value
Edit this page
- View Source
+ View Source
UpStreamEndPointIPv6
@@ -2892,7 +2895,7 @@ Property Value
Edit this page
- View Source
+ View Source
UpStreamHttpProxy
@@ -2923,7 +2926,7 @@ Property Value
Edit this page
- View Source
+ View Source
UpStreamHttpsProxy
@@ -3089,7 +3092,7 @@ Parameters
Edit this page
- View Source
+ View Source
ApplyLoggingConfiguration()
@@ -4088,7 +4091,7 @@ Events
Edit this page
- View Source
+ View Source
AfterResponse
Intercept after response event from server.
@@ -4118,7 +4121,7 @@
Event Type
Edit this page
- View Source
+ View Source
BeforeRequest
Intercept request event to server.
@@ -4148,7 +4151,7 @@
Event Type
Edit this page
- View Source
+ View Source
BeforeResponse
Intercept response event from server.
@@ -4178,7 +4181,7 @@
Event Type
Edit this page
- View Source
+ View Source
BeforeUpStreamConnectRequest
Intercept connect request sent to upstream proxy.
@@ -4208,7 +4211,7 @@
Event Type
Edit this page
- View Source
+ View Source
ClientCertificateSelectionCallback
Event to override client certificate selection during mutual SSL authentication.
@@ -4238,7 +4241,7 @@
Event Type
Edit this page
- View Source
+ View Source
ClientConnectionCountChanged
Event occurs when client connection count changed.
@@ -4299,7 +4302,7 @@
Event Type
Edit this page
- View Source
+ View Source
Http3ClientConnectionCountChanged
Event occurs when inbound HTTP/3 client connection count changed.
@@ -4329,7 +4332,7 @@
Event Type
Edit this page
- View Source
+ View Source
Http3ServerConnectionCountChanged
Event occurs when upstream HTTP/3 server connection count changed.
@@ -4359,7 +4362,7 @@
Event Type
Edit this page
- View Source
+ View Source
OnClientConnectionCreate
Customize TcpClient used for client connection upon create.
@@ -4389,7 +4392,7 @@
Event Type
Edit this page
- View Source
+ View Source
OnRequestBodyWrite
Intercept request body send event to server.
@@ -4421,7 +4424,7 @@
Event Type
Edit this page
- View Source
+ View Source
OnResponseBodyWrite
Intercept response body send event to client.
@@ -4453,7 +4456,7 @@
Event Type
Edit this page
- View Source
+ View Source
OnServerConnectionCreate
Customize TcpClient used for server connection upon create.
@@ -4483,7 +4486,7 @@
Event Type
Edit this page
- View Source
+ View Source
ServerCertificateValidationCallback
Event to override the default verification logic of remote SSL certificate received during authentication.
@@ -4513,7 +4516,7 @@
Event Type
Edit this page
- View Source
+ View Source
ServerConnectionCountChanged
Event occurs when server connection count changed.
diff --git a/docs/index.json b/docs/index.json
index 22c7fbe6a..16c515417 100644
--- a/docs/index.json
+++ b/docs/index.json
@@ -502,7 +502,7 @@
"api/Titanium.Web.Proxy.Options.PolicyFamily.html": {
"href": "api/Titanium.Web.Proxy.Options.PolicyFamily.html",
"title": "Enum PolicyFamily | Titanium Web Proxy",
- "summary": "Enum PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public enum PolicyFamily Fields Name Description AdmissionControl The global and per-endpoint admission gates that bound concurrently admitted client connections. BodyBudget Cumulative whole-body buffering limits (MaxBufferedBodyBytes and the MaxEncodedBodyBytes/MaxDecodedBodyBytes pair) enforced via Titanium.Web.Proxy.Network.Streams.BoundedWriteStream across H1, H2 and H3. DecompressionRatio The compressed-input/decompressed-output byte budgets and the expansion-ratio ceiling (MaxDecompressionRatio) applied while draining a Content-Encoding chain, so a small compressed body cannot expand unboundedly in memory before BodyBudget's own decoded-byte cap would catch it. Today, that decoded-byte cap is exactly what protects this case in practice: the decompression chain writes into the same Titanium.Web.Proxy.Network.Streams.BoundedWriteStream- wrapped target BodyBudget already bounds, so a small compressed body that expands enormously is caught the moment the decoded output crosses that limit, without needing a separately computed ratio. MaxDecompressionRatio and the encoded/decoded byte pair remain reserved for a future, more precise per-stream computation; this family's mode exists now so a profile can name it, but changing it has no additional effect while BodyBudget already covers the same paths. HeaderLimits Header line length, header count and aggregate header-byte limits (MaxHeaderLineBytes/MaxHeaderCount/ MaxHeaderAggregateBytes) intended for the request/response header-block read. Reserved, like DecompressionRatio, for numeric enforcement not yet wired to every header-reading call site; this family's mode exists so a profile can name it ahead of that work landing. The client request-line/header deadline (a different, already-enforced protection - see ProxyServer.ClientHeaderTimeoutSeconds and DeadlineRegistry) is unaffected by this family's mode. Http2AbuseBudget HTTP/2 abuse budgets: the open-header-block CONTINUATION frame-count/wall-clock bound and the peer-initiated incomplete-stream-reset budget."
+ "summary": "Enum PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public enum PolicyFamily Fields Name Description AdmissionControl The global and per-endpoint admission gates that bound concurrently admitted client connections. BodyBudget Cumulative whole-body buffering limits (MaxBufferedBodyBytes and the MaxEncodedBodyBytes/MaxDecodedBodyBytes pair) enforced via Titanium.Web.Proxy.Network.Streams.BoundedWriteStream across H1, H2 and H3. DecompressionRatio The compressed-input/decompressed-output byte budgets and the expansion-ratio ceiling (MaxDecompressionRatio) applied while draining a Content-Encoding chain, so a small compressed body cannot expand unboundedly in memory before BodyBudget's own decoded-byte cap would catch it. Today, that decoded-byte cap is exactly what protects this case in practice: the decompression chain writes into the same Titanium.Web.Proxy.Network.Streams.BoundedWriteStream- wrapped target BodyBudget already bounds, so a small compressed body that expands enormously is caught the moment the decoded output crosses that limit, without needing a separately computed ratio. MaxDecompressionRatio and the encoded/decoded byte pair remain reserved for a future, more precise per-stream computation; this family's mode exists now so a profile can name it, but changing it has no additional effect while BodyBudget already covers the same paths. HeaderLimits Header line length, header count and aggregate header-byte limits (MaxHeaderLineBytes/MaxHeaderCount/ MaxHeaderAggregateBytes) intended for the request/response header-block read. Reserved, like DecompressionRatio, for numeric enforcement not yet wired to every header-reading call site; this family's mode exists so a profile can name it ahead of that work landing. The client request-line/header deadline (a different, already-enforced protection - see ProxyServer.ClientHeaderTimeoutSeconds and DeadlineRegistry) is unaffected by this family's mode. Http2AbuseBudget HTTP/2 abuse budgets: the open-header-block CONTINUATION frame-count/wall-clock bound and the peer-initiated incomplete-stream-reset budget. Http2RelayValidation Whether HPACK header blocks on the H2↔H2 compressed-relay path (httpInterceptionEnabled = false) are semantically validated per RFC 9113 §8.3. Unlike framing (always enforced, no Observe action), header semantics can be logged without corrupting connection state. Disabled (default on Balanced) skips HPACK decode entirely — maximum throughput when upstream peers are trusted. Observe decodes and records semantic violations without rejecting the stream. Does not mutate headers, so the compressed-relay MutationCount fast path is unaffected when the family is Disabled. Enforce (default on PublicFacing and AllEnforce) decodes and sends GOAWAY(PROTOCOL_ERROR) on violations."
},
"api/Titanium.Web.Proxy.Options.PolicyMode.html": {
"href": "api/Titanium.Web.Proxy.Options.PolicyMode.html",
@@ -512,7 +512,7 @@
"api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html": {
"href": "api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html",
"title": "Class ProxyPolicyModes | Titanium Web Proxy",
- "summary": "Class ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. Inheritance object ProxyPolicyModes Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public sealed class ProxyPolicyModes Properties | Edit this page View Source AllEnforce Every family enforced - today's shipped behavior, and the starting point every profile builds from. Declaration public static ProxyPolicyModes AllEnforce { get; } Property Value Type Description ProxyPolicyModes | Edit this page View Source AllowAmbiguousFraming Off by default and absent from every profile - see the type-level remarks. Never true unless WithAllowAmbiguousFramingEnabled() was called explicitly. Declaration public bool AllowAmbiguousFraming { get; } Property Value Type Description bool | Edit this page View Source this[PolicyFamily] Returns the mode selected for family. Declaration public PolicyMode this[PolicyFamily family] { get; } Parameters Type Name Description PolicyFamily family Property Value Type Description PolicyMode Methods | Edit this page View Source Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) Builds a snapshot with an explicit mode for every family. AllowAmbiguousFraming starts false. Declaration public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget) Parameters Type Name Description PolicyMode bodyBudget PolicyMode decompressionRatio PolicyMode headerLimits PolicyMode admissionControl PolicyMode http2AbuseBudget Returns Type Description ProxyPolicyModes | Edit this page View Source With(PolicyFamily, PolicyMode) Returns a snapshot identical to this one but with mode for family. Declaration public ProxyPolicyModes With(PolicyFamily family, PolicyMode mode) Parameters Type Name Description PolicyFamily family PolicyMode mode Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllObservedExceptDisabled() Returns a snapshot identical to this one but with every family dropped to Observe, except families already Disabled (which stay disabled - Observe would silently turn a family back on). This is the \"drop to Observe without redeploying\" runtime switch the plan's rollout section requires. Declaration public ProxyPolicyModes WithAllObservedExceptDisabled() Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllowAmbiguousFramingEnabled() The single, explicit, isolated act of relaying ambiguous HTTP/1 framing instead of rejecting it. See the type-level remarks; never call this as a side effect of applying a profile. Declaration public ProxyPolicyModes WithAllowAmbiguousFramingEnabled() Returns Type Description ProxyPolicyModes"
+ "summary": "Class ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. Inheritance object ProxyPolicyModes Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Options Assembly: Titanium.Web.Proxy.dll Syntax public sealed class ProxyPolicyModes Properties | Edit this page View Source AllEnforce Every family enforced - today's shipped behavior, and the starting point every profile builds from. Declaration public static ProxyPolicyModes AllEnforce { get; } Property Value Type Description ProxyPolicyModes | Edit this page View Source AllowAmbiguousFraming Off by default and absent from every profile - see the type-level remarks. Never true unless WithAllowAmbiguousFramingEnabled() was called explicitly. Declaration public bool AllowAmbiguousFraming { get; } Property Value Type Description bool | Edit this page View Source this[PolicyFamily] Returns the mode selected for family. Declaration public PolicyMode this[PolicyFamily family] { get; } Parameters Type Name Description PolicyFamily family Property Value Type Description PolicyMode Methods | Edit this page View Source Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) Builds a snapshot with an explicit mode for every family. AllowAmbiguousFraming starts false. Declaration public static ProxyPolicyModes Create(PolicyMode bodyBudget, PolicyMode decompressionRatio, PolicyMode headerLimits, PolicyMode admissionControl, PolicyMode http2AbuseBudget, PolicyMode http2RelayValidation = PolicyMode.Disabled) Parameters Type Name Description PolicyMode bodyBudget PolicyMode decompressionRatio PolicyMode headerLimits PolicyMode admissionControl PolicyMode http2AbuseBudget PolicyMode http2RelayValidation Returns Type Description ProxyPolicyModes | Edit this page View Source With(PolicyFamily, PolicyMode) Returns a snapshot identical to this one but with mode for family. Declaration public ProxyPolicyModes With(PolicyFamily family, PolicyMode mode) Parameters Type Name Description PolicyFamily family PolicyMode mode Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllObservedExceptDisabled() Returns a snapshot identical to this one but with every family dropped to Observe, except families already Disabled (which stay disabled - Observe would silently turn a family back on). This is the \"drop to Observe without redeploying\" runtime switch the plan's rollout section requires. Declaration public ProxyPolicyModes WithAllObservedExceptDisabled() Returns Type Description ProxyPolicyModes | Edit this page View Source WithAllowAmbiguousFramingEnabled() The single, explicit, isolated act of relaying ambiguous HTTP/1 framing instead of rejecting it. See the type-level remarks; never call this as a side effect of applying a profile. Declaration public ProxyPolicyModes WithAllowAmbiguousFramingEnabled() Returns Type Description ProxyPolicyModes"
},
"api/Titanium.Web.Proxy.Options.ProxyProfile.html": {
"href": "api/Titanium.Web.Proxy.Options.ProxyProfile.html",
@@ -542,7 +542,7 @@
"api/Titanium.Web.Proxy.Options.html": {
"href": "api/Titanium.Web.Proxy.Options.html",
"title": "Namespace Titanium.Web.Proxy.Options | Titanium Web Proxy",
- "summary": "Namespace Titanium.Web.Proxy.Options Classes ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. ProxyProfileSettings The full bundle of settings one ProxyProfile applies to a ProxyServer as a single atomic assignment via Profile. Deliberately a plain data bundle, not a type with behavior: applying it is ProxyServer's job (see Profile's setter), so this type has no back-reference and no side effects of its own, per the plan's \"Constraints on the policy layer\" section. ProxyResourceLimits Immutable, validated snapshot of the resource bounds a peer can make the proxy allocate: header shape, body/decompression budgets, concurrency and abuse-rate ceilings, and pool / certificate-cache sizing. Constructed only through Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?), which validates every field up front, so an invalid limit is a construction-time exception rather than a runtime surprise discovered mid-connection. A limit that can legitimately be turned off is typed as nullable with null meaning \"disabled\" - an explicit state - rather than overloading 0 or a negative number to mean the same thing. Limits that must always be enforced because disabling them would leave the proxy itself exploitable (the concurrent-stream cap, the open-header-block frame bound) are non-nullable and always validated to be strictly positive. This type has no back-reference to ProxyServer and no mutable state after construction: it is meant to be handed down to subsystems by value, not looked up through a service locator or an ambient static, so the dependency graph among consumers stays acyclic per the plan's \"Constraints on the policy layer\" section. ProxyTimeoutOptions Immutable, validated snapshot of every deadline the proxy enforces across a request's lifetime, expressed consistently as TimeSpan rather than the mixture of \"seconds as int\" properties this replaces (ConnectionTimeOutSeconds, ConnectTimeOutSeconds, ...). A deadline that can be legitimately unbounded is nullable, with null meaning \"no deadline\" as an explicit state rather than Zero or a magic sentinel duration. This type only holds values; it does not decide which deadline fired first when several are composed for one request. That is the responsibility of the per-request deadline registry described in the plan's \"Deadline composition\" section, introduced alongside the header-parsing deadlines in a later item so it can be exercised by a real caller instead of landing as unused scaffolding. ResolvedSessionPolicy Read-only snapshot combining ProxyResourceLimits and ProxyTimeoutOptions into the single object H1/H2/H3/WebSocket subsystems are handed, so runtime mutation of either half cannot produce inconsistent enforcement partway through a request that started under a different combination of the two. Per the plan's \"Constraints on the policy layer\" section, this type is deliberately inert: no back-pointer to ProxyServer, no service-locator lookup, no mutable fields, no static ambient accessor. Subsystems receive it as a constructor argument or method parameter only, so the dependency graph among consumers stays acyclic - the opposite of how ProxyServer itself is reached today. Per the plan's \"Two-phase policy resolution\" section, a single resolution per connection is not correct: SessionEventArgs.MaxBufferedBodyBytes is contractually settable from BeforeRequest, and HTTP/3 already reads the request body before BeforeRequest fires. This type does not itself perform either resolution phase - that is the responsibility of the call sites introduced in later hardening-plan items, once there is a real per-session override path to resolve against - but it is deliberately shaped so a caller can hold one instance at headers-complete time (ResourceLimits's framing/header-shape fields, which are never overridable) and, if a session lowers a body or streaming budget in BeforeRequest, build a second instance via Create(ProxyResourceLimits, ProxyTimeoutOptions) that shares the same Timeouts but substitutes a ProxyResourceLimits reflecting the override, rather than mutating the first instance in place. Enums PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. PolicyMode How a resource-bound policy family is applied once its numeric limit is breached, per the plan's \"Rollout, profiles and documentation\" section. Not every family supports every mode. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no Observe mode at all: an ambiguous chunk size or a conflicting length can only be forwarded (a desync) or rejected, so those call sites are unconditionally enforced and never consult a PolicyMode. ProxyPolicyModes exists for the families where a safe \"detect but let it through\" middle ground is actually possible. ProxyProfile The three shipped profiles, per the plan's \"Rollout, profiles and documentation\" section. Selecting a profile via Profile applies its ProxyProfileSettings atomically to resource limits, policy modes, TLS protocols, private-network blocking, admission caps, and deadline-second properties, so a caller can never observe a half-applied profile."
+ "summary": "Namespace Titanium.Web.Proxy.Options Classes ProxyPolicyModes Immutable snapshot of the PolicyMode selected for each PolicyFamily, plus the one deliberately-separate AllowAmbiguousFraming escape hatch. Read live by enforcement call sites through PolicyModes, which the plan's rollout section requires to be a runtime switch: replacing the whole snapshot (see PolicyModes's setter) rather than mutating a field lets an operator drop every family to Observe without redeploying, while every in-flight request that already read the previous snapshot keeps behaving consistently with whichever snapshot it observed. AllowAmbiguousFraming is not a PolicyFamily member and has no corresponding PolicyMode: framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no safe \"detect but let it through\" middle ground, so this is a single named, binary, off-by-default flag that relays malformed framing instead of rejecting it - useful only for security research that needs to observe how a client or origin reacts to smuggling-shaped input through the proxy. No profile sets it: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode) never accepts it as a parameter, and the only way to turn it on is the explicit WithAllowAmbiguousFramingEnabled() call, so enabling it can never be a side effect of selecting a profile. ProxyProfileSettings The full bundle of settings one ProxyProfile applies to a ProxyServer as a single atomic assignment via Profile. Deliberately a plain data bundle, not a type with behavior: applying it is ProxyServer's job (see Profile's setter), so this type has no back-reference and no side effects of its own, per the plan's \"Constraints on the policy layer\" section. ProxyResourceLimits Immutable, validated snapshot of the resource bounds a peer can make the proxy allocate: header shape, body/decompression budgets, concurrency and abuse-rate ceilings, and pool / certificate-cache sizing. Constructed only through Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?), which validates every field up front, so an invalid limit is a construction-time exception rather than a runtime surprise discovered mid-connection. A limit that can legitimately be turned off is typed as nullable with null meaning \"disabled\" - an explicit state - rather than overloading 0 or a negative number to mean the same thing. Limits that must always be enforced because disabling them would leave the proxy itself exploitable (the concurrent-stream cap, the open-header-block frame bound) are non-nullable and always validated to be strictly positive. This type has no back-reference to ProxyServer and no mutable state after construction: it is meant to be handed down to subsystems by value, not looked up through a service locator or an ambient static, so the dependency graph among consumers stays acyclic per the plan's \"Constraints on the policy layer\" section. ProxyTimeoutOptions Immutable, validated snapshot of every deadline the proxy enforces across a request's lifetime, expressed consistently as TimeSpan rather than the mixture of \"seconds as int\" properties this replaces (ConnectionTimeOutSeconds, ConnectTimeOutSeconds, ...). A deadline that can be legitimately unbounded is nullable, with null meaning \"no deadline\" as an explicit state rather than Zero or a magic sentinel duration. This type only holds values; it does not decide which deadline fired first when several are composed for one request. That is the responsibility of the per-request deadline registry described in the plan's \"Deadline composition\" section, introduced alongside the header-parsing deadlines in a later item so it can be exercised by a real caller instead of landing as unused scaffolding. ResolvedSessionPolicy Read-only snapshot combining ProxyResourceLimits and ProxyTimeoutOptions into the single object H1/H2/H3/WebSocket subsystems are handed, so runtime mutation of either half cannot produce inconsistent enforcement partway through a request that started under a different combination of the two. Per the plan's \"Constraints on the policy layer\" section, this type is deliberately inert: no back-pointer to ProxyServer, no service-locator lookup, no mutable fields, no static ambient accessor. Subsystems receive it as a constructor argument or method parameter only, so the dependency graph among consumers stays acyclic - the opposite of how ProxyServer itself is reached today. Per the plan's \"Two-phase policy resolution\" section, a single resolution per connection is not correct: SessionEventArgs.MaxBufferedBodyBytes is contractually settable from BeforeRequest, and HTTP/3 already reads the request body before BeforeRequest fires. This type does not itself perform either resolution phase - that is the responsibility of the call sites introduced in later hardening-plan items, once there is a real per-session override path to resolve against - but it is deliberately shaped so a caller can hold one instance at headers-complete time (ResourceLimits's framing/header-shape fields, which are never overridable) and, if a session lowers a body or streaming budget in BeforeRequest, build a second instance via Create(ProxyResourceLimits, ProxyTimeoutOptions) that shares the same Timeouts but substitutes a ProxyResourceLimits reflecting the override, rather than mutating the first instance in place. Enums PolicyFamily The resource-bound policy families that support an PolicyMode other than Enforce, per the plan's rollout section. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution are deliberately not members of this enum: they are always enforced and never consult a mode, because there is no safe Observe action for an ambiguous or malformed message - see ProxyPolicyModes and AllowAmbiguousFraming for the one explicit, isolated escape hatch from that rule. PolicyMode How a resource-bound policy family is applied once its numeric limit is breached, per the plan's \"Rollout, profiles and documentation\" section. Not every family supports every mode. Framing, chunk parsing and Content-Length/Transfer-Encoding resolution have no Observe mode at all: an ambiguous chunk size or a conflicting length can only be forwarded (a desync) or rejected, so those call sites are unconditionally enforced and never consult a PolicyMode. ProxyPolicyModes exists for the families where a safe \"detect but let it through\" middle ground is actually possible. ProxyProfile The three shipped profiles, per the plan's \"Rollout, profiles and documentation\" section. Selecting a profile via Profile applies its ProxyProfileSettings atomically to resource limits, policy modes, TLS protocols, private-network blocking, admission caps, and deadline-second properties, so a caller can never observe a half-applied profile."
},
"api/Titanium.Web.Proxy.ProxyLimits.html": {
"href": "api/Titanium.Web.Proxy.ProxyLimits.html",
@@ -552,7 +552,7 @@
"api/Titanium.Web.Proxy.ProxyServer.html": {
"href": "api/Titanium.Web.Proxy.ProxyServer.html",
"title": "Class ProxyServer | Titanium Web Proxy",
- "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Fields | Edit this page View Source DefaultViaHeaderPseudonym Default Via header pseudonym (RFC 9110 §7.6.3). Used by ViaHeaderPseudonym and by Inspector when Add Via header is enabled. Declaration public const string DefaultViaHeaderPseudonym = \"titanium-web-proxy\" Field Value Type Description string Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func
>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DecryptFailureBypassMaxEntries Maximum learned hosts retained (approximate LRU eviction). Default 256. Declaration public int DecryptFailureBypassMaxEntries { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassThreshold Origin TLS failure strikes required before a host is bypassed on later CONNECTs. Same-CONNECT opaque fallback after an awaited H2 probe failure marks bypass immediately. Default 2. Declaration public int DecryptFailureBypassThreshold { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassTtl How long a learned decrypt-bypass entry remains valid. Default 30 minutes. Declaration public TimeSpan DecryptFailureBypassTtl { get; set; } Property Value Type Description TimeSpan | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableDecryptFailureBypass When true, the proxy learns hosts whose origin TLS handshake fails under MITM (non-ALPN AuthenticationException, typically bot / TLS-fingerprint rejection) and tunnels subsequent CONNECTs without decrypt. Default false so library and RPS baselines are unchanged. Inspector enables this by default. Success-path cost when on is one dictionary lookup per CONNECT. Declaration public bool EnableDecryptFailureBypass { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability selects outbound HTTP/3 on the next CONNECT or new HTTP/1.1 request. Background QUIC warm-up starts when the cache is filled so that handshake is often already done. An already-open H2↔H2 MITM session is not upgraded mid-connection. Forced Http3 fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with no inbound HTTP/3 endpoint is fine when an explicit/SOCKS/transparent TCP endpoint is present (origin-side QUIC only). A warning is emitted only when EnableHttp3 is set with no client-facing endpoints at all. Default: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, the proxy enables RFC 8441 WebSocket-over-HTTP/2: Accepts extended CONNECT (:protocol = websocket) from h2 clients and advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to them. Per origin: if the origin is HTTP/2 and advertises RFC 8441 support, DATA frames are relayed directly; if the origin is HTTP/2 and does not, the stream is reset with REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. On the HTTP/1.1-client-to-h2-origin translation bridge, translates Upgrade: websocket into extended CONNECT when the origin advertises the setting; otherwise falls back to a dedicated HTTP/1.1 origin connection for that WebSocket. When this property is false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IgnoreServerCertificateErrors When true, origin TLS certificates that fail OS chain validation are still accepted (MITM of loopback/self-signed/private CAs). Inspector's \"Ignore server certificate errors\" maps here. Default false. A subscribed ServerCertificateValidationCallback still wins. Declaration public bool IgnoreServerCertificateErrors { get; set; } Property Value Type Description bool | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to AllEnforce, matching Balanced. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to DefaultViaHeaderPseudonym. Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source ClearDecryptFailureBypass() Clears all learned decrypt-bypass entries. Declaration public void ClearDecryptFailureBypass() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source ForceDecryptFailureBypass(string) Marks host as actively bypassed (same as a forced learn after origin TLS failure). Raises DecryptFailureBypassChanged when bypass newly becomes active. Declaration public bool ForceDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source GetDecryptFailureBypassEntries() Snapshot of current learned decrypt-bypass entries (may include non-active strikes). Declaration public IReadOnlyList GetDecryptFailureBypassEntries() Returns Type Description IReadOnlyList | Edit this page View Source RemoveDecryptFailureBypass(string) Removes one host from the learned decrypt-bypass cache. Declaration public bool RemoveDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source ShouldBypassDecryptForLearnedHost(string?) When EnableDecryptFailureBypass is on and host is actively bypassed, returns true (decrypt should be skipped). Declaration public bool ShouldBypassDecryptForLearnedHost(string? host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryDisableAllSystemProxies() Clear all OS proxy settings without throwing. Declaration public SystemProxyChangeResult TryDisableAllSystemProxies() Returns Type Description SystemProxyChangeResult | Edit this page View Source TryDisableSystemProxy(ProxyProtocolType) Clear OS proxy for the given protocols without throwing. Declaration public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType Returns Type Description SystemProxyChangeResult | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool | Edit this page View Source TryRestoreOriginalProxySettings() Restore OS proxy without throwing. Declaration public SystemProxyChangeResult TryRestoreOriginalProxySettings() Returns Type Description SystemProxyChangeResult | Edit this page View Source TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI can show a status message instead of crashing. Declaration public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null) Parameters Type Name Description ExplicitProxyEndPoint endPoint ProxyProtocolType protocolType SystemProxySettings settings Returns Type Description SystemProxyChangeResult Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source DecryptFailureBypassChanged Raised when a host becomes actively bypassed (threshold reached or same-CONNECT mark). Handlers must not block; Inspector marshals to the UI thread. Declaration public event EventHandler? DecryptFailureBypassChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable"
+ "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Fields | Edit this page View Source DefaultViaHeaderPseudonym Default Via header pseudonym (RFC 9110 §7.6.3). Used by ViaHeaderPseudonym and by Inspector when Add Via header is enabled. Declaration public const string DefaultViaHeaderPseudonym = \"titanium-web-proxy\" Field Value Type Description string Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DecryptFailureBypassMaxEntries Maximum learned hosts retained (approximate LRU eviction). Default 256. Declaration public int DecryptFailureBypassMaxEntries { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassThreshold Origin TLS failure strikes required before a host is bypassed on later CONNECTs. Same-CONNECT opaque fallback after an awaited H2 probe failure marks bypass immediately. Default 2. Declaration public int DecryptFailureBypassThreshold { get; set; } Property Value Type Description int | Edit this page View Source DecryptFailureBypassTtl How long a learned decrypt-bypass entry remains valid. Default 30 minutes. Declaration public TimeSpan DecryptFailureBypassTtl { get; set; } Property Value Type Description TimeSpan | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableDecryptFailureBypass When true, the proxy learns hosts whose origin TLS handshake fails under MITM (non-ALPN AuthenticationException, typically bot / TLS-fingerprint rejection) and tunnels subsequent CONNECTs without decrypt. Default false so library and RPS baselines are unchanged. Inspector enables this by default. Success-path cost when on is one dictionary lookup per CONNECT. Declaration public bool EnableDecryptFailureBypass { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability selects outbound HTTP/3 on the next CONNECT or new HTTP/1.1 request. Background QUIC warm-up starts when the cache is filled so that handshake is often already done. An already-open H2↔H2 MITM session is not upgraded mid-connection. Forced Http3 fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with no inbound HTTP/3 endpoint is fine when an explicit/SOCKS/transparent TCP endpoint is present (origin-side QUIC only). A warning is emitted only when EnableHttp3 is set with no client-facing endpoints at all. Default: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, the proxy enables RFC 8441 WebSocket-over-HTTP/2: Accepts extended CONNECT (:protocol = websocket) from h2 clients and advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to them. Per origin: if the origin is HTTP/2 and advertises RFC 8441 support, DATA frames are relayed directly; if the origin is HTTP/2 and does not, the stream is reset with REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. On the HTTP/1.1-client-to-h2-origin translation bridge, translates Upgrade: websocket into extended CONNECT when the origin advertises the setting; otherwise falls back to a dedicated HTTP/1.1 origin connection for that WebSocket. When this property is false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IgnoreServerCertificateErrors When true, origin TLS certificates that fail OS chain validation are still accepted (MITM of loopback/self-signed/private CAs). Inspector's \"Ignore server certificate errors\" maps here. Default false. A subscribed ServerCertificateValidationCallback still wins. Declaration public bool IgnoreServerCertificateErrors { get; set; } Property Value Type Description bool | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to Balanced's modes (resource families enforced, Http2RelayValidation disabled so compressed H2 relay stays the verbatim-HPACK fast path). AllEnforce additionally enforces relay validation and is what PublicFacing applies. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to DefaultViaHeaderPseudonym. Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source ClearDecryptFailureBypass() Clears all learned decrypt-bypass entries. Declaration public void ClearDecryptFailureBypass() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source ForceDecryptFailureBypass(string) Marks host as actively bypassed (same as a forced learn after origin TLS failure). Raises DecryptFailureBypassChanged when bypass newly becomes active. Declaration public bool ForceDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source GetDecryptFailureBypassEntries() Snapshot of current learned decrypt-bypass entries (may include non-active strikes). Declaration public IReadOnlyList GetDecryptFailureBypassEntries() Returns Type Description IReadOnlyList | Edit this page View Source RemoveDecryptFailureBypass(string) Removes one host from the learned decrypt-bypass cache. Declaration public bool RemoveDecryptFailureBypass(string host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source ShouldBypassDecryptForLearnedHost(string?) When EnableDecryptFailureBypass is on and host is actively bypassed, returns true (decrypt should be skipped). Declaration public bool ShouldBypassDecryptForLearnedHost(string? host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryDisableAllSystemProxies() Clear all OS proxy settings without throwing. Declaration public SystemProxyChangeResult TryDisableAllSystemProxies() Returns Type Description SystemProxyChangeResult | Edit this page View Source TryDisableSystemProxy(ProxyProtocolType) Clear OS proxy for the given protocols without throwing. Declaration public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType Returns Type Description SystemProxyChangeResult | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool | Edit this page View Source TryRestoreOriginalProxySettings() Restore OS proxy without throwing. Declaration public SystemProxyChangeResult TryRestoreOriginalProxySettings() Returns Type Description SystemProxyChangeResult | Edit this page View Source TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI can show a status message instead of crashing. Declaration public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null) Parameters Type Name Description ExplicitProxyEndPoint endPoint ProxyProtocolType protocolType SystemProxySettings settings Returns Type Description SystemProxyChangeResult Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source DecryptFailureBypassChanged Raised when a host becomes actively bypassed (threshold reached or same-CONNECT mark). Handlers must not block; Inspector marshals to the UI thread. Declaration public event EventHandler? DecryptFailureBypassChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable"
},
"api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html": {
"href": "api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html",
diff --git a/docs/xrefmap.yml b/docs/xrefmap.yml
index 765dcab44..49e4d677b 100644
--- a/docs/xrefmap.yml
+++ b/docs/xrefmap.yml
@@ -8000,6 +8000,12 @@ references:
commentId: F:Titanium.Web.Proxy.Options.PolicyFamily.Http2AbuseBudget
fullName: Titanium.Web.Proxy.Options.PolicyFamily.Http2AbuseBudget
nameWithType: PolicyFamily.Http2AbuseBudget
+- uid: Titanium.Web.Proxy.Options.PolicyFamily.Http2RelayValidation
+ name: Http2RelayValidation
+ href: api/Titanium.Web.Proxy.Options.PolicyFamily.html#Titanium_Web_Proxy_Options_PolicyFamily_Http2RelayValidation
+ commentId: F:Titanium.Web.Proxy.Options.PolicyFamily.Http2RelayValidation
+ fullName: Titanium.Web.Proxy.Options.PolicyFamily.Http2RelayValidation
+ nameWithType: PolicyFamily.Http2RelayValidation
- uid: Titanium.Web.Proxy.Options.PolicyMode
name: PolicyMode
href: api/Titanium.Web.Proxy.Options.PolicyMode.html
@@ -8056,12 +8062,12 @@ references:
isSpec: "True"
fullName: Titanium.Web.Proxy.Options.ProxyPolicyModes.AllowAmbiguousFraming
nameWithType: ProxyPolicyModes.AllowAmbiguousFraming
-- uid: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode)
- name: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)
- href: api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html#Titanium_Web_Proxy_Options_ProxyPolicyModes_Create_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_
- commentId: M:Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode)
- fullName: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode)
- nameWithType: ProxyPolicyModes.Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)
+- uid: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode)
+ name: Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)
+ href: api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html#Titanium_Web_Proxy_Options_ProxyPolicyModes_Create_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_Titanium_Web_Proxy_Options_PolicyMode_
+ commentId: M:Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode,Titanium.Web.Proxy.Options.PolicyMode)
+ fullName: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create(Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode, Titanium.Web.Proxy.Options.PolicyMode)
+ nameWithType: ProxyPolicyModes.Create(PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode, PolicyMode)
- uid: Titanium.Web.Proxy.Options.ProxyPolicyModes.Create*
name: Create
href: api/Titanium.Web.Proxy.Options.ProxyPolicyModes.html#Titanium_Web_Proxy_Options_ProxyPolicyModes_Create_
From 601293507bba3d0e094ff220fba00766c93c3b0d Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 21:30:31 +0900
Subject: [PATCH 32/63] Clear remaining new-code Sonar smells on protocol
helpers.
Pass session cancellation into the cert-validation Task.Run, share the DATA padding PROTOCOL_ERROR string, and split streamed H3 response writes so SendResponseAsync stays under the complexity cap.
---
.../Handlers/CertificateHandler.cs | 3 +-
.../Http2/Http2OriginConnection.cs | 21 ++++++----
.../Http3/Http3RequestStream.cs | 42 ++++++++++++-------
3 files changed, 41 insertions(+), 25 deletions(-)
diff --git a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
index 78803720b..1d719114a 100644
--- a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
@@ -1,6 +1,7 @@
using System;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
+using System.Threading;
using System.Threading.Tasks;
using Titanium.Web.Proxy.EventArguments;
using Titanium.Web.Proxy.Extensions;
@@ -33,7 +34,7 @@ internal bool ValidateServerCertificate(object sender, SessionEventArgsBase? ses
// a captured SynchronizationContext cannot deadlock the handshake thread.
var pending = ServerCertificateValidationCallback.InvokeAsync(this, args, logger);
if (!pending.IsCompletedSuccessfully)
- Task.Run(() => pending).GetAwaiter().GetResult();
+ Task.Run(() => pending, sessionArgs.CancellationToken).GetAwaiter().GetResult();
return args.IsValid;
}
diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
index 01e5f98fe..f432a7ff3 100644
--- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
+++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs
@@ -51,6 +51,9 @@ internal sealed class Http2OriginConnection : IDisposable
/// Every HTTP/2 endpoint must accept frames up to this size (RFC 7540 §4.2), so it is always safe to send.
private const int SafeMaxFrameSize = 16384;
+ private const string DataPaddingProtocolError =
+ "HTTP/2 protocol error: DATA padding length is the payload length or longer.";
+
/// Maximum total header block (HEADERS + CONTINUATION fragments) we accept from origin before treating it as a protocol violation.
private const int MaxHeaderBlockBytes = 256 * 1024;
@@ -1413,8 +1416,7 @@ private static ReadOnlySpan StripDataFramingSpan(ReadOnlySpan payloa
return payload;
var padLength = payload[0];
- if (1 + padLength > payload.Length)
- throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ ThrowIfDataPaddingTooLong(padLength, payload.Length);
return payload.Slice(1, payload.Length - 1 - padLength);
}
@@ -1425,8 +1427,7 @@ private static byte[] StripDataFraming(ReadOnlySpan payload, Http2FrameFla
return payload.ToArray();
var padLength = payload[0];
- if (1 + padLength > payload.Length)
- throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ ThrowIfDataPaddingTooLong(padLength, payload.Length);
return payload.Slice(1, payload.Length - 1 - padLength).ToArray();
}
@@ -1439,8 +1440,7 @@ private static byte[] StripDataFraming(byte[] payload, Http2FrameFlag flags) //
if ((flags & Http2FrameFlag.Padded) == 0 || payload.Length == 0) return payload;
var padLength = payload[0];
- if (1 + padLength > payload.Length)
- throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ ThrowIfDataPaddingTooLong(padLength, payload.Length);
return payload.AsSpan(1, payload.Length - 1 - padLength).ToArray();
}
@@ -1452,11 +1452,16 @@ private static ReadOnlyMemory StripDataFramingMemory(
return payload.AsMemory(0, payloadLength);
var padLength = payload[0];
- if (1 + padLength > payloadLength)
- throw new IOException("HTTP/2 protocol error: DATA padding length is the payload length or longer.");
+ ThrowIfDataPaddingTooLong(padLength, payloadLength);
return payload.AsMemory(1, payloadLength - 1 - padLength);
}
+ private static void ThrowIfDataPaddingTooLong(int padLength, int payloadLength)
+ {
+ if (1 + padLength > payloadLength)
+ throw new IOException(DataPaddingProtocolError);
+ }
+
private void ProcessHeaderBlock(int streamId, ReadOnlySpan compressed, bool endStream) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk.
{
// Decode into the Response's own HeaderCollection (or a temporary for 1xx) so we do not
diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
index ebfcd0c86..dc3161d8f 100644
--- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
+++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs
@@ -1155,22 +1155,7 @@ private static async Task SendResponseAsync(QuicStream stream, Response response
if (response.StreamBodyWriter != null && !response.IsBodySent)
{
- await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct);
- // Http3OriginBridge streams the origin body; drain it as DATA frames (same contract as
- // H1 BodyStreamWriter / H2 EmitSyntheticResponseAsync).
- // Http3DataBodyWriter never FINs (completeWrites stays false) so trailers can follow.
- var bodyWriter = new Http3DataBodyWriter(stream);
- await response.StreamBodyWriter(bodyWriter, ct);
- response.IsBodySent = true;
- if (hasTrailers)
- {
- var trailerBlock = QpackEncoder.Encode(
- response.TrailingHeaders.Select(h => (h.Name, h.Value)), qpackContext);
- await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, trailerBlock, ct, completeWrites: true);
- }
-
- // Always Flush — Darwin MsQuic requires an explicit Flush before FIN is observed.
- await stream.FlushAsync(ct);
+ await SendStreamedResponseAsync(stream, response, qpackHeaders, qpackContext, hasTrailers, ct);
return;
}
@@ -1221,6 +1206,31 @@ private static async Task SendResponseAsync(QuicStream stream, Response response
await stream.FlushAsync(ct);
}
+ ///
+ /// HEADERS + streamed DATA (+ optional trailer HEADERS) for origin-bridged H3 bodies.
+ ///
+ private static async Task SendStreamedResponseAsync(
+ QuicStream stream, Response response, ReadOnlyMemory qpackHeaders,
+ QpackContext? qpackContext, bool hasTrailers, CancellationToken ct)
+ {
+ await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct);
+ // Http3OriginBridge streams the origin body; drain it as DATA frames (same contract as
+ // H1 BodyStreamWriter / H2 EmitSyntheticResponseAsync).
+ // Http3DataBodyWriter never FINs (completeWrites stays false) so trailers can follow.
+ var bodyWriter = new Http3DataBodyWriter(stream);
+ await response.StreamBodyWriter!(bodyWriter, ct);
+ response.IsBodySent = true;
+ if (hasTrailers)
+ {
+ var trailerBlock = QpackEncoder.Encode(
+ response.TrailingHeaders.Select(h => (h.Name, h.Value)), qpackContext);
+ await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, trailerBlock, ct, completeWrites: true);
+ }
+
+ // Always Flush — Darwin MsQuic requires an explicit Flush before FIN is observed.
+ await stream.FlushAsync(ct);
+ }
+
private static string StatusCodeString(int statusCode) => statusCode switch
{
200 => "200",
From 8869e8101869005a013f0c8232b61d7f19a36e32 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Wed, 16 Sep 2026 22:20:18 +0900
Subject: [PATCH 33/63] Opt cert-validation Task.Run out of session
cancellation.
Passing sessionArgs.CancellationToken into Task.Run satisfied S8949 but raised first-chance OperationCanceledException on keep-alive Happy Path integration. Use CancellationToken.None with an explicit opt-out rationale instead.
---
src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
index 1d719114a..6e1e1fdf0 100644
--- a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
+++ b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs
@@ -32,9 +32,12 @@ internal bool ValidateServerCertificate(object sender, SessionEventArgsBase? ses
// handshake path parked a worker even for Task.CompletedTask (probe loopback CA).
// When not already completed, run on the ThreadPool so a user callback that posts back to
// a captured SynchronizationContext cannot deadlock the handshake thread.
+ // Opt out of session cancellation explicitly (S8949): observing sessionArgs.CancellationToken
+ // here surfaces OperationCanceledException as a first-chance on keep-alive happy paths when
+ // the token races the handshake, and canceling mid-callback would leave SslStream half-done.
var pending = ServerCertificateValidationCallback.InvokeAsync(this, args, logger);
if (!pending.IsCompletedSuccessfully)
- Task.Run(() => pending, sessionArgs.CancellationToken).GetAwaiter().GetResult();
+ Task.Run(() => pending, CancellationToken.None).GetAwaiter().GetResult();
return args.IsValid;
}
From bbd27438bba1012fb799a46d3c3fd261f538f401 Mon Sep 17 00:00:00 2001
From: justcoding121
Date: Thu, 17 Sep 2026 03:22:57 +0900
Subject: [PATCH 34/63] Publish RPS wiki/charts from full Win/Linux/Mac suite @
8bfa7852.
Paste product, heavier, gRPC, and WebSocket tables from the completed sharded GHA runs (including mac-retries), regenerate practical charts, and surface the macOS 64 KB chart on the website.
---
tools/RpsLoadProbe/paste-heavier-wiki.py | 27 +-
website/docs/performance.md | 6 +-
wiki/Performance.md | 612 +++++++++---------
wiki/images/rps-practical-heavier-linux.png | Bin 100089 -> 100847 bytes
wiki/images/rps-practical-heavier-macos.png | Bin 0 -> 100202 bytes
wiki/images/rps-practical-heavier-windows.png | Bin 101323 -> 96991 bytes
wiki/images/rps-practical-linux.png | Bin 89956 -> 90258 bytes
wiki/images/rps-practical-macos.png | Bin 90714 -> 93116 bytes
wiki/images/rps-practical-windows.png | Bin 90453 -> 90951 bytes
9 files changed, 331 insertions(+), 314 deletions(-)
create mode 100644 wiki/images/rps-practical-heavier-macos.png
diff --git a/tools/RpsLoadProbe/paste-heavier-wiki.py b/tools/RpsLoadProbe/paste-heavier-wiki.py
index 83c4d6d8b..afd33440d 100644
--- a/tools/RpsLoadProbe/paste-heavier-wiki.py
+++ b/tools/RpsLoadProbe/paste-heavier-wiki.py
@@ -12,9 +12,9 @@
ROOT = Path("tools/RpsLoadProbe/results/gha-dl")
WIKI = Path("wiki/Performance.md")
-HEAD = "9a2b3a1e"
+HEAD = "8bfa7852"
RUNS = {
- # Linux H3 HAProxy/Envoy peers remasured @ a495a9ae (2026-09-11); Windows still 9a2b3a1e wiki-grade batch.
+ # Wiki-grade batch @ 8bfa7852 (2026-09-16); prior Linux H3 HAProxy/Envoy peers re-measured @ a495a9ae.
"saturation": [34441539402, 34441541578],
"bodies": [34557778171, 34557780393, 34441570199, 34441572485, 34441574457, 34441576323],
"post": [34557782264, 34441591377, 34441593359],
@@ -485,9 +485,9 @@ def rdiv(num: Optional[dict], den: Optional[dict]) -> str:
block = text[a:b]
w = block.find("**Windows**")
l = block.find("**Linux**")
- block = replace_table_at(block, block.find("| Arm | Generator | Sustain", w), sat_block_a(win["saturation"]))
+ block = replace_table_at(block, block.find("| Arm | Generator | RPS", w), sat_block_a(win["saturation"]))
l = block.find("**Linux**")
- block = replace_table_at(block, block.find("| Arm | Generator | Sustain", l), sat_block_a(lin["saturation"]))
+ block = replace_table_at(block, block.find("| Arm | Generator | RPS", l), sat_block_a(lin["saturation"]))
text = text[:a] + block + text[b:]
# Block B
@@ -510,7 +510,12 @@ def rdiv(num: Optional[dict], den: Optional[dict]) -> str:
# Block C
c = text.find("#### Block C — H3→H1")
- how = text.find("**How to read the tables**")
+ # End of saturation section (do not use early "## How to read the tables" TOC heading)
+ how = text.find("\n## Windows — Titanium", c)
+ if how < 0:
+ how = text.find("\n## Windows", c)
+ if how < 0:
+ raise SystemExit("missing end of saturation Block C")
block = text[c:how]
w = block.find("**Windows**")
block = replace_table_at(
@@ -591,8 +596,8 @@ def patch_heavier(heading: str, new_hdr: str, new_table: str) -> None:
)
text = re.sub(
- r"Median of \*\*3\*\* repeats on matched 4 vCPU / 16 GiB runners @ `[^`]+` \(\[[0-9]+\]\([^)]+\)\) \(`compare-arch`\)\.",
- f"Median of **3** repeats on matched 4 vCPU / 16 GiB runners @ `{HEAD}` ([{rid_a}]({run_url(rid_a)})) (`compare-arch`).",
+ r"Median of \*\*3\*\* repeats on matched 4 vCPU / 16 GiB runners @ `[^`]+` \(\[[0-9]+\]\([^)]+\)\)(?: \(`compare-arch`\))?\.",
+ f"Median of **3** repeats on matched 4 vCPU / 16 GiB runners @ `{HEAD}` ([{rid_a}]({run_url(rid_a)})).",
text,
count=1,
)
@@ -608,8 +613,8 @@ def patch_heavier(heading: str, new_hdr: str, new_table: str) -> None:
w = text.find("#### Windows", tls)
text2 = text[w:]
text2 = re.sub(
- r"Median of \*\*3\*\* repeats on `windows-latest` @ `[^`]+`\. Source: Actions \[[0-9]+\]\([^)]+\) \(`compare-tls-cost`\)\.[^\n]*\n",
- f"Median of **3** repeats on `windows-latest` @ `{HEAD}`. Source: Actions [{rid_t}]({run_url(rid_t)}) (`compare-tls-cost`). Absolute RPS on GHA swings hard; prefer **TWP÷YARP**.\n",
+ r"Median of \*\*3\*\* repeats on `windows-latest` @ `[^`]+`\. Source: Actions \[[0-9]+\]\([^)]+\)(?: \(`compare-tls-cost`\))?\.[^\n]*\n",
+ f"Median of **3** repeats on `windows-latest` @ `{HEAD}`. Source: Actions [{rid_t}]({run_url(rid_t)}). Absolute RPS on GHA swings hard; prefer **TWP÷YARP**.\n",
text2,
count=1,
)
@@ -621,8 +626,8 @@ def patch_heavier(heading: str, new_hdr: str, new_table: str) -> None:
l = text.find("#### Linux", tls)
text2 = text[l:]
text2 = re.sub(
- r"Median of \*\*3\*\* repeats @ `[^`]+`\. Source: Actions \[[0-9]+\]\([^)]+\) \(`compare-tls-cost`\)\.\n",
- f"Median of **3** repeats @ `{HEAD}`. Source: Actions [{rid_t}]({run_url(rid_t)}) (`compare-tls-cost`).\n",
+ r"Median of \*\*3\*\* repeats @ `[^`]+`\. Source: Actions \[[0-9]+\]\([^)]+\)(?: \(`compare-tls-cost`\))?\.\n",
+ f"Median of **3** repeats @ `{HEAD}`. Source: Actions [{rid_t}]({run_url(rid_t)}).\n",
text2,
count=1,
)
diff --git a/website/docs/performance.md b/website/docs/performance.md
index 2765667c1..256133cb5 100644
--- a/website/docs/performance.md
+++ b/website/docs/performance.md
@@ -22,7 +22,7 @@ Common reverse wires with **tiny keep-alive GET (~56 B)**, plus WebSocket and un
## Practical reverse RPS (64 KB)
-Typical reverse wires with **64 KB GET/POST** (plus 256 KB H1 terminate) — body work separate from the tiny-GET chart above. Windows and Linux below; macOS heavier bodies are not published yet.
+Typical reverse wires with **64 KB GET/POST** (plus 256 KB H1 terminate) — body work separate from the tiny-GET chart above.
### Windows
@@ -32,6 +32,10 @@ Typical reverse wires with **64 KB GET/POST** (plus 256 KB H1 terminate) — bod

+### macOS
+
+
+
## Heavier reverse workloads
Larger bodies, POST, lossy links, TLS termination cost, and architecture-sensitive shapes (slow consumers, duplex, WebSocket H1 Upgrade). Full tables are on the [Performance wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance#heavier-reverse-workloads).
diff --git a/wiki/Performance.md b/wiki/Performance.md
index f3d64ca11..55a461c55 100644
--- a/wiki/Performance.md
+++ b/wiki/Performance.md
@@ -100,7 +100,7 @@ Do **not** use `macos-latest` (Apple Silicon, 3-core / 7 GB) for publishable sat
### Saturation control
-Calibration for the shared 4 vCPU loopback shape: how close client + origin are to saturated before ranking reverse peers. Tiny keep-alive GET. Median of **3** repeats @ `9a2b3a1e` — [34441539402](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441539402). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Block A **% of origin-HttpClient** uses median **peak** RPS. Blocks B/C use peer÷YARP / ÷nginx on median peak (not % of H1 origin). **RPS cells** embed median RSS / CPU for the **proxy child** plus its **full descendant tree** (serve-proxy → nginx master → workers); origin-direct samples the **origin** child. Product matrices below use matched `dotnet-httpclient` only (not bombardier).
+Calibration for the shared 4 vCPU loopback shape: how close client + origin are to saturated before ranking reverse peers. Tiny keep-alive GET. Median of **3** repeats @ `8bfa7852` — [35092854050](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092854050). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Block A **% of origin-HttpClient** uses median **peak** RPS. Blocks B/C use peer÷YARP / ÷nginx on median peak (not % of H1 origin). **RPS cells** embed median RSS / CPU for the **proxy child** plus its **full descendant tree** (serve-proxy → nginx master → workers); origin-direct samples the **origin** child. Product matrices below use matched `dotnet-httpclient` only (not bombardier).
#### Block A — H1 plain
@@ -115,23 +115,23 @@ Calibration for the shared 4 vCPU loopback shape: how close client + origin are
| Arm | Generator | RPS | % of origin-HttpClient |
|---|---|---:|---:|
-| origin-direct | dotnet-httpclient | **50,319**(55 MiB / 40.4% CPU) | **100.0%** |
-| origin-direct-bombardier | bombardier | **38,565**(56 MiB / 21.8% CPU) | **76.6%** |
-| bare-reverse-http1 | dotnet-httpclient | **25,016**(58 MiB / 43.8% CPU) | **49.7%** |
-| nginx-reverse-http1 | dotnet-httpclient | **13,405**(125 MiB / 24.8% CPU) | **26.6%** |
-| yarp-reverse-http1 | dotnet-httpclient | **20,802**(87 MiB / 49.0% CPU) | **41.3%** |
-| twp-reverse-http1 | dotnet-httpclient | 🥇 **24,558**(75 MiB / 48.0% CPU) | **48.8%** |
+| origin-direct | dotnet-httpclient | **81,217**(55 MiB / 45.3% CPU) | **100.0%** |
+| origin-direct-bombardier | bombardier | **61,721**(56 MiB / 25.1% CPU) | **76.0%** |
+| bare-reverse-http1 | dotnet-httpclient | **39,529**(56 MiB / 44.1% CPU) | **48.7%** |
+| nginx-reverse-http1 | dotnet-httpclient | **24,677**(125 MiB / 24.9% CPU) | **30.4%** |
+| yarp-reverse-http1 | dotnet-httpclient | **34,602**(90 MiB / 49.2% CPU) | **42.6%** |
+| twp-reverse-http1 | dotnet-httpclient | 🥇 **40,685**(76 MiB / 50.9% CPU) | **50.1%** |
**Linux** (`ubuntu-latest`)
| Arm | Generator | RPS | % of origin-HttpClient |
|---|---|---:|---:|
-| origin-direct | dotnet-httpclient | **102,038**(80 MiB / 44.2% CPU) | **100.0%** |
-| origin-direct-bombardier | bombardier | **61,434**(80 MiB / 37.4% CPU) | **60.2%** |
-| bare-reverse-http1 | dotnet-httpclient | **46,442**(70 MiB / 46.0% CPU) | **45.5%** |
-| nginx-reverse-http1 | dotnet-httpclient | 🥇 **56,585**(76 MiB / 40.3% CPU) | **55.5%** |
-| yarp-reverse-http1 | dotnet-httpclient | **41,835**(116 MiB / 49.3% CPU) | **41.0%** |
-| twp-reverse-http1 | dotnet-httpclient | **47,721**(95 MiB / 50.9% CPU) | **46.8%** |
+| origin-direct | dotnet-httpclient | **131,605**(81 MiB / 45.0% CPU) | **100.0%** |
+| origin-direct-bombardier | bombardier | **81,228**(80 MiB / 38.3% CPU) | **61.7%** |
+| bare-reverse-http1 | dotnet-httpclient | **58,771**(70 MiB / 46.0% CPU) | **44.7%** |
+| nginx-reverse-http1 | dotnet-httpclient | 🥇 **74,446**(77 MiB / 39.5% CPU) | **56.6%** |
+| yarp-reverse-http1 | dotnet-httpclient | **55,568**(115 MiB / 48.0% CPU) | **42.2%** |
+| twp-reverse-http1 | dotnet-httpclient | **63,570**(92 MiB / 50.3% CPU) | **48.3%** |
Reverse peers are about **50–46%** of the origin-direct HttpClient peak on this runner class (Win TWP **50.0%**, Lin TWP **45.6%**). Prefer the **%** column over absolute RPS across runs. Bare and origin-direct are controls (not medal peers).
@@ -143,17 +143,17 @@ Peer ratios (÷YARP / ÷nginx) on median peak; **RPS cells** embed `(MiB / CPU%)
| Arm | Generator | RPS | ÷YARP | ÷nginx |
|---|---|---:|---:|---:|
-| nginx-reverse-http2 | dotnet-httpclient | **7,992**(141 MiB / 24.6% CPU) | **0.28×** | **1.00×** |
-| yarp-reverse-http2 | dotnet-httpclient | **28,294**(97 MiB / 54.9% CPU) | **1.00×** | **3.54×** |
-| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **33,720**(105 MiB / 52.2% CPU) | **1.19×** | **4.22×** |
+| nginx-reverse-http2 | dotnet-httpclient | **14,697**(141 MiB / 24.4% CPU) | **0.32×** | **1.00×** |
+| yarp-reverse-http2 | dotnet-httpclient | **45,876**(96 MiB / 50.8% CPU) | **1.00×** | **3.12×** |
+| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **51,822**(103 MiB / 52.9% CPU) | **1.13×** | **3.53×** |
**Linux** (`ubuntu-latest`)
| Arm | Generator | RPS | ÷YARP | ÷nginx |
|---|---|---:|---:|---:|
-| nginx-reverse-http2 | dotnet-httpclient | **23,276**(102 MiB / 18.9% CPU) | **0.53×** | **1.00×** |
-| yarp-reverse-http2 | dotnet-httpclient | **44,299**(122 MiB / 48.0% CPU) | **1.00×** | **1.90×** |
-| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **49,167**(124 MiB / 52.1% CPU) | **1.11×** | **2.11×** |
+| nginx-reverse-http2 | dotnet-httpclient | **29,423**(102 MiB / 18.9% CPU) | **0.46×** | **1.00×** |
+| yarp-reverse-http2 | dotnet-httpclient | **64,174**(123 MiB / 46.6% CPU) | **1.00×** | **2.18×** |
+| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **66,465**(123 MiB / 51.4% CPU) | **1.04×** | **2.26×** |
#### Block C — H3→H1
@@ -164,232 +164,230 @@ Same layout as Block B. Requires QuicListener. nginx needs `http_v3_module` (Win
| Arm | Generator | RPS | ÷YARP | ÷nginx |
|---|---|---:|---:|---:|
| nginx-reverse-http3-cleartext | dotnet-httpclient | *Not possible (no QUIC)* | — | — |
-| yarp-reverse-http3-cleartext | dotnet-httpclient | **14,041**(142 MiB / 51.8% CPU) | **1.00×** | — |
-| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **14,348**(104 MiB / 43.8% CPU) | **1.02×** | — |
+| yarp-reverse-http3-cleartext | dotnet-httpclient | **24,601**(148 MiB / 50.5% CPU) | **1.00×** | — |
+| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **24,701**(117 MiB / 43.9% CPU) | **1.00×** | — |
**Linux** (`ubuntu-latest`)
| Arm | Generator | RPS | ÷YARP | ÷nginx |
|---|---|---:|---:|---:|
-| nginx-reverse-http3-cleartext | dotnet-httpclient | **0**(peak 24,928 · 110 MiB / 21.8% CPU) | **0.89×** | **1.00×** |
-| yarp-reverse-http3-cleartext | dotnet-httpclient | **27,936**(195 MiB / 48.8% CPU) | **1.00×** | **1.12×** |
-| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **30,636**(159 MiB / 52.7% CPU) | **1.10×** | **1.23×** |
+| nginx-reverse-http3-cleartext | dotnet-httpclient | **0**(peak 38,114 · 113 MiB / 20.9% CPU) | **0.96×** | **1.00×** |
+| yarp-reverse-http3-cleartext | dotnet-httpclient | **39,807**(215 MiB / 48.7% CPU) | **1.00×** | **1.04×** |
+| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **44,892**(177 MiB / 53.4% CPU) | **1.13×** | **1.18×** |
-## Windows — Titanium vs nginx vs YARP
+## Windows — Titanium vs nginx vs HAProxy vs Envoy vs YARP
Client / origin: HTTP version and whether TLS is used (`plain` = cleartext, `TLS` = encrypted, `QUIC` = HTTP/3).
### Reverse
-Median of **3 repeats** on `windows-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `9a2b3a1e` — `compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** show sustain RPS; `` holds peak (when higher) plus median RSS / CPU at the peak-RPS step. nginx terminate peers use `keepalive 256` + streaming buffers. Laptop High-perf / cool-paired numbers stay on the [local lab](Performance-Local-Lab). Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair).
+Median of **3 repeats** on `windows-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `8bfa7852` — `compare-product` [35092827644](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092827644). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as `(MiB / CPU%) `. nginx terminate peers use `keepalive 256` + streaming buffers. **HAProxy / Envoy are Linux-only peers** (no official Windows port). Laptop High-perf / cool-paired numbers stay on the [local lab](Performance-Local-Lab). Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair).
-*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
-
-**Load generators:** Reverse inbound H3 arms use **`dotnet-httpclient`** (`http_version=3.0`, `RequestVersionExact`). nginx/Windows is same-OS only (no QUIC).
+**Load generators:** Reverse inbound H3 arms use **`dotnet-httpclient`** (`http_version=3.0`, `RequestVersionExact`). nginx/Windows is same-OS only (no QUIC). HAProxy/Envoy are Linux-only terminate peers.
| Client | Origin | TWP | nginx | YARP |
|---|---|---:|---:|---:|
-| HTTP/1 · plain | HTTP/1 · plain | 🥇 **23,134**(75 MiB / 47.5% CPU) | **13,916**(125 MiB / 24.9% CPU) | **21,699**(86 MiB / 48.8% CPU) |
-| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **20,749**(89 MiB / 52.8% CPU) | **8,366**(135 MiB / 24.6% CPU) | **18,784**(100 MiB / 49% CPU) |
-| HTTP/1 · plain | HTTP/2 · plain | 🥇 **45,733**(115 MiB / 50.5% CPU) | *Not possible (no H2 upstream)* | **39,820**(90 MiB / 49.2% CPU) |
-| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **32,162**(120 MiB / 46.7% CPU) | *Not possible (no H2 upstream)* | **29,670**(98 MiB / 49.7% CPU) |
-| HTTP/1 · plain | HTTP/3 · QUIC | 🥇 **18,315**(106 MiB / 50.6% CPU) | *Not possible (no H3 upstream)* | **17,823**(117 MiB / 51% CPU) |
-| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **25,720**(90 MiB / 48.4% CPU) | **12,987**(141 MiB / 24.8% CPU) | **22,690**(102 MiB / 48.2% CPU) |
-| HTTP/1 · TLS | HTTP/1 · TLS | 🥇 **18,942**(91 MiB / 48.6% CPU) | **7,218**(143 MiB / 24.8% CPU) | **17,296**(104 MiB / 49.7% CPU) |
-| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **27,614**(111 MiB / 44.5% CPU) | *Not possible (no H2 upstream)* | **26,015**(104 MiB / 47.6% CPU) |
-| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **32,132**(117 MiB / 45.4% CPU) | *Not possible (no H2 upstream)* | **29,999**(103 MiB / 48.2% CPU) |
-| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **16,275**(110 MiB / 51.3% CPU) | *Not possible (no H3 upstream)* | **15,797**(124 MiB / 51.4% CPU) |
-| HTTP/2 · plain | HTTP/1 · plain | 🥇 **34,933**(89 MiB / 55% CPU) | **9,362**(127 MiB / 24.4% CPU) | **31,625**(86 MiB / 54.4% CPU) |
-| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **35,086**(105 MiB / 49% CPU) | **8,849**(138 MiB / 24.9% CPU) | **32,740**(92 MiB / 50.1% CPU) |
-| HTTP/2 · plain | HTTP/2 · plain | 🥇 **112,638**(61 MiB / 28.3% CPU) | *Not possible (no H2 upstream)* | **64,290**(90 MiB / 49.6% CPU) |
-| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **70,105**(62 MiB / 20.1% CPU) | *Not possible (no H2 upstream)* | **43,971**(99 MiB / 35.9% CPU) |
-| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **33,088**(128 MiB / 53.1% CPU) | *Not possible (no H3 upstream)* | **31,406**(130 MiB / 52% CPU) |
-| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **34,943**(97 MiB / 52.2% CPU) | **8,430**(141 MiB / 24.8% CPU) | **29,441**(96 MiB / 53.8% CPU) |
-| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **29,487**(97 MiB / 53.6% CPU) | **6,542**(144 MiB / 24.6% CPU) | **25,117**(98 MiB / 54.2% CPU) |
-| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **107,152**(75 MiB / 29.1% CPU) | *Not possible (no H2 upstream)* | **59,098**(102 MiB / 52.7% CPU) |
-| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **84,487**(73 MiB / 28.8% CPU) | *Not possible (no H2 upstream)* | **49,546**(98 MiB / 49% CPU) |
-| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **30,034**(127 MiB / 53.4% CPU) | *Not possible (no H3 upstream)* | **25,937**(123 MiB / 51.5% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **19,348**(107 MiB / 44% CPU) | *Not possible (no QUIC)* | **18,041**(143 MiB / 50.9% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **13,646**(113 MiB / 46.7% CPU) | *Not possible (no QUIC)* | **13,437**(145 MiB / 52.4% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **34,128**(136 MiB / 47.6% CPU) | *Not possible (no H2 upstream)* | **25,450**(166 MiB / 50.1% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **33,850**(138 MiB / 48% CPU) | *Not possible (no H2 upstream)* | **26,828**(168 MiB / 48.5% CPU) |
-| HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 **23,150**(121 MiB / 41.8% CPU) | *Not possible (no H3 upstream)* | **12,064**(167 MiB / 53.1% CPU) |
+| HTTP/1 · plain | HTTP/1 · plain | 🥇 **31,552**(75 MiB / 51.1% CPU) | **19,301**(125 MiB / 25% CPU) | **26,979**(90 MiB / 48.1% CPU) |
+| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **20,447**(90 MiB / 52.6% CPU) | **8,419**(136 MiB / 24.6% CPU) | **18,937**(100 MiB / 48.3% CPU) |
+| HTTP/1 · plain | HTTP/2 · plain | 🥇 **36,987**(108 MiB / 45.2% CPU) | *Not possible (no H2 upstream)* | **32,831**(93 MiB / 46.7% CPU) |
+| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **39,580**(117 MiB / 47.3% CPU) | *Not possible (no H2 upstream)* | **36,173**(98 MiB / 48.9% CPU) |
+| HTTP/1 · plain | HTTP/3 · QUIC | **11,519**(104 MiB / 35% CPU) | *Not possible (no H3 upstream)* | 🥇 **18,177**(118 MiB / 49.9% CPU) |
+| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **20,182**(86 MiB / 53.9% CPU) | **8,534**(142 MiB / 24.7% CPU) | **18,049**(102 MiB / 49.4% CPU) |
+| HTTP/1 · TLS | HTTP/1 · TLS | 🥇 **23,518**(84 MiB / 47.8% CPU) | **9,376**(144 MiB / 24.8% CPU) | **20,816**(102 MiB / 51.2% CPU) |
+| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **27,742**(115 MiB / 46% CPU) | *Not possible (no H2 upstream)* | **26,111**(108 MiB / 49.6% CPU) |
+| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **25,046**(119 MiB / 47% CPU) | *Not possible (no H2 upstream)* | **24,356**(108 MiB / 47.4% CPU) |
+| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **19,385**(111 MiB / 50.9% CPU) | *Not possible (no H3 upstream)* | **18,399**(129 MiB / 49.7% CPU) |
+| HTTP/2 · plain | HTTP/1 · plain | 🥇 **34,785**(95 MiB / 53.6% CPU) | **9,225**(127 MiB / 24.8% CPU) | **31,535**(84 MiB / 53.4% CPU) |
+| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **29,745**(98 MiB / 49% CPU) | **6,559**(138 MiB / 24.9% CPU) | **27,120**(96 MiB / 54% CPU) |
+| HTTP/2 · plain | HTTP/2 · plain | 🥇 **114,586**(59 MiB / 27.4% CPU) | *Not possible (no H2 upstream)* | **69,424**(94 MiB / 48.1% CPU) |
+| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **86,454**(65 MiB / 28.5% CPU) | *Not possible (no H2 upstream)* | **55,488**(99 MiB / 46.8% CPU) |
+| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **31,645**(121 MiB / 52.4% CPU) | *Not possible (no H3 upstream)* | **28,884**(120 MiB / 52.8% CPU) |
+| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **39,258**(103 MiB / 51.6% CPU) | **11,247**(141 MiB / 24.3% CPU) | **35,070**(94 MiB / 49% CPU) |
+| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **29,467**(106 MiB / 54% CPU) | **6,379**(145 MiB / 24.6% CPU) | **25,223**(97 MiB / 51.8% CPU) |
+| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **101,094**(78 MiB / 26.5% CPU) | *Not possible (no H2 upstream)* | **54,554**(99 MiB / 50.8% CPU) |
+| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **88,782**(75 MiB / 28.5% CPU) | *Not possible (no H2 upstream)* | **53,218**(103 MiB / 48.9% CPU) |
+| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **30,154**(129 MiB / 54.2% CPU) | *Not possible (no H3 upstream)* | **26,201**(123 MiB / 51.9% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **14,399**(101 MiB / 45.8% CPU) | *Not possible (no QUIC)* | **14,330**(142 MiB / 51.1% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **17,592**(116 MiB / 44.1% CPU) | *Not possible (no QUIC)* | **15,607**(147 MiB / 48.8% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **34,116**(136 MiB / 47.7% CPU) | *Not possible (no H2 upstream)* | **23,176**(151 MiB / 47.4% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **31,135**(137 MiB / 47.1% CPU) | *Not possible (no H2 upstream)* | **21,466**(150 MiB / 48.8% CPU) |
+| HTTP/3 · QUIC | HTTP/3 · QUIC | **17,411**(119 MiB / 41.5% CPU) | *Not possible (no H3 upstream)* | 🥇 **18,619**(163 MiB / 48.3% CPU) |
### MITM (TWP only)
-Same Client×Origin wires with interception on (`compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.75×** (no terminate-peer gate).
+Same Client×Origin wires with interception on (`compare-product` [35092827644](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092827644)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.75×** (no terminate-peer gate).
**v1 append-only relay (2026-08-27):** Pre-fix H2→H2 Full÷Reverse was **0.13–0.16×** ([32960766249](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32960766249)). Post-fix @ `df172718`: H2 plain→H2 plain Full **0.77–0.79×**, H3→H1 Full **0.91–0.93×**, all MITM arms ≥ **0.70×** on median of [33041445371](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33041445371), [33055267086](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055267086), [33055272140](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055272140).
-**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `9a2b3a1e`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin).
+**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `8bfa7852`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin).
| Client | Origin | Lite sustain | Full sustain | Lite÷Reverse | Full÷Reverse |
|---|---|---:|---:|---:|---:|
-| HTTP/1 · plain | HTTP/1 · plain | **25054**(82 MiB / 50.4% CPU) | **24740**(82 MiB / 51.6% CPU) | **1.08×** | **1.07×** |
-| HTTP/1 · plain | HTTP/1 · TLS | **20351**(95 MiB / 54.2% CPU) | **19992**(95 MiB / 52% CPU) | **0.98×** | **0.96×** |
-| HTTP/1 · plain | HTTP/2 · plain | **45468**(110 MiB / 51.5% CPU) | **43600**(120 MiB / 49.4% CPU) | **0.99×** | **0.95×** |
-| HTTP/1 · plain | HTTP/2 · TLS | **32619**(118 MiB / 46.8% CPU) | **31475**(125 MiB / 46.3% CPU) | **1.01×** | **0.98×** |
-| HTTP/1 · plain | HTTP/3 · QUIC | **17858**(105 MiB / 47.7% CPU) | **17488**(104 MiB / 52.6% CPU) | **0.98×** | **0.95×** |
-| HTTP/1 · TLS | HTTP/1 · plain | **24388**(98 MiB / 47.9% CPU) | **24773**(95 MiB / 48.2% CPU) | **0.95×** | **0.96×** |
-| HTTP/1 · TLS | HTTP/1 · TLS | **18766**(94 MiB / 49.4% CPU) | **18334**(96 MiB / 49.4% CPU) | **0.99×** | **0.97×** |
-| HTTP/1 · TLS | HTTP/2 · plain | **27503**(112 MiB / 47.3% CPU) | **27245**(112 MiB / 46.1% CPU) | **1×** | **0.99×** |
-| HTTP/1 · TLS | HTTP/2 · TLS | **31677**(118 MiB / 47.9% CPU) | **30700**(117 MiB / 44.2% CPU) | **0.99×** | **0.96×** |
-| HTTP/1 · TLS | HTTP/3 · QUIC | **15862**(113 MiB / 50.9% CPU) | **15726**(112 MiB / 50.8% CPU) | **0.97×** | **0.97×** |
-| HTTP/2 · plain | HTTP/1 · plain | **34123**(95 MiB / 55.4% CPU) | **33304**(96 MiB / 56.8% CPU) | **0.98×** | **0.95×** |
-| HTTP/2 · plain | HTTP/1 · TLS | **34643**(106 MiB / 50.5% CPU) | **34092**(109 MiB / 52.9% CPU) | **0.99×** | **0.97×** |
-| HTTP/2 · plain | HTTP/2 · plain | **86231**(71 MiB / 41.3% CPU) | **80246**(73 MiB / 41.9% CPU) | **0.77×** | **0.71×** |
-| HTTP/2 · plain | HTTP/2 · TLS | **72044**(77 MiB / 36.5% CPU) | **67973**(77 MiB / 39.7% CPU) | **1.03×** | **0.97×** |
-| HTTP/2 · plain | HTTP/3 · QUIC | **33179**(124 MiB / 53.4% CPU) | **32949**(129 MiB / 53.1% CPU) | **1×** | **1×** |
-| HTTP/2 · TLS | HTTP/1 · plain | **33820**(98 MiB / 55.2% CPU) | **32652**(107 MiB / 54.9% CPU) | **0.97×** | **0.93×** |
-| HTTP/2 · TLS | HTTP/1 · TLS | **28427**(100 MiB / 56.2% CPU) | **27770**(105 MiB / 53.2% CPU) | **0.96×** | **0.94×** |
-| HTTP/2 · TLS | HTTP/2 · plain | **84248**(86 MiB / 40.5% CPU) | **80554**(84 MiB / 39.7% CPU) | **0.79×** | **0.75×** |
-| HTTP/2 · TLS | HTTP/2 · TLS | **68743**(86 MiB / 38.1% CPU) | **65184**(81 MiB / 39.6% CPU) | **0.81×** | **0.77×** |
-| HTTP/2 · TLS | HTTP/3 · QUIC | **29843**(126 MiB / 54.4% CPU) | **29276**(133 MiB / 53.5% CPU) | **0.99×** | **0.97×** |
-| HTTP/3 · QUIC | HTTP/1 · plain | **18648**(117 MiB / 44.9% CPU) | **17458**(114 MiB / 44.7% CPU) | **0.96×** | **0.9×** |
-| HTTP/3 · QUIC | HTTP/1 · TLS | **12964**(123 MiB / 46.8% CPU) | **12392**(116 MiB / 47.5% CPU) | **0.95×** | **0.91×** |
-| HTTP/3 · QUIC | HTTP/2 · plain | **30904**(132 MiB / 48.6% CPU) | **29726**(133 MiB / 50.6% CPU) | **0.91×** | **0.87×** |
-| HTTP/3 · QUIC | HTTP/2 · TLS | **31489**(143 MiB / 48.5% CPU) | **30510**(143 MiB / 48.3% CPU) | **0.93×** | **0.9×** |
-| HTTP/3 · QUIC | HTTP/3 · QUIC | **16061**(120 MiB / 49.2% CPU) | **15312**(119 MiB / 49.5% CPU) | **0.69×** | **0.66×** |
+| HTTP/1 · plain | HTTP/1 · plain | **30,988**(80 MiB / 46.4% CPU) | **30,492**(82 MiB / 48.2% CPU) | **0.98×** | **0.97×** |
+| HTTP/1 · plain | HTTP/1 · TLS | **20,384**(95 MiB / 52.8% CPU) | **20,198**(95 MiB / 52.5% CPU) | **1×** | **0.99×** |
+| HTTP/1 · plain | HTTP/2 · plain | **35,601**(110 MiB / 48% CPU) | **35,122**(105 MiB / 49.1% CPU) | **0.96×** | **0.95×** |
+| HTTP/1 · plain | HTTP/2 · TLS | **39,383**(120 MiB / 48.3% CPU) | **38,670**(120 MiB / 47.9% CPU) | **1×** | **0.98×** |
+| HTTP/1 · plain | HTTP/3 · QUIC | **18,054**(103 MiB / 53.2% CPU) | **17,685**(107 MiB / 52.2% CPU) | **1.57×** | **1.54×** |
+| HTTP/1 · TLS | HTTP/1 · plain | **19,722**(94 MiB / 51.6% CPU) | **19,661**(96 MiB / 51.5% CPU) | **0.98×** | **0.97×** |
+| HTTP/1 · TLS | HTTP/1 · TLS | **23,590**(95 MiB / 48.8% CPU) | **23,182**(96 MiB / 46.8% CPU) | **1×** | **0.99×** |
+| HTTP/1 · TLS | HTTP/2 · plain | **27,300**(110 MiB / 48.4% CPU) | **26,872**(112 MiB / 47.3% CPU) | **0.98×** | **0.97×** |
+| HTTP/1 · TLS | HTTP/2 · TLS | **25,107**(124 MiB / 45.4% CPU) | **24,748**(127 MiB / 47.5% CPU) | **1×** | **0.99×** |
+| HTTP/1 · TLS | HTTP/3 · QUIC | **18,795**(112 MiB / 50.6% CPU) | **18,674**(115 MiB / 53.7% CPU) | **0.97×** | **0.96×** |
+| HTTP/2 · plain | HTTP/1 · plain | **33,811**(97 MiB / 55.8% CPU) | **33,355**(100 MiB / 55.9% CPU) | **0.97×** | **0.96×** |
+| HTTP/2 · plain | HTTP/1 · TLS | **29,166**(105 MiB / 53.2% CPU) | **28,519**(103 MiB / 55.1% CPU) | **0.98×** | **0.96×** |
+| HTTP/2 · plain | HTTP/2 · plain | **90,897**(69 MiB / 41.3% CPU) | **84,998**(70 MiB / 43% CPU) | **0.79×** | **0.74×** |
+| HTTP/2 · plain | HTTP/2 · TLS | **70,465**(78 MiB / 38.9% CPU) | **66,829**(79 MiB / 38.1% CPU) | **0.82×** | **0.77×** |
+| HTTP/2 · plain | HTTP/3 · QUIC | **31,297**(116 MiB / 54.3% CPU) | **30,424**(115 MiB / 55.6% CPU) | **0.99×** | **0.96×** |
+| HTTP/2 · TLS | HTTP/1 · plain | **38,437**(103 MiB / 53.4% CPU) | **37,561**(112 MiB / 51.9% CPU) | **0.98×** | **0.96×** |
+| HTTP/2 · TLS | HTTP/1 · TLS | **28,202**(105 MiB / 55.9% CPU) | **27,538**(102 MiB / 54.8% CPU) | **0.96×** | **0.93×** |
+| HTTP/2 · TLS | HTTP/2 · plain | **79,360**(87 MiB / 40.9% CPU) | **74,807**(87 MiB / 40.4% CPU) | **0.79×** | **0.74×** |
+| HTTP/2 · TLS | HTTP/2 · TLS | **74,209**(81 MiB / 39.1% CPU) | **70,572**(85 MiB / 39.3% CPU) | **0.84×** | **0.79×** |
+| HTTP/2 · TLS | HTTP/3 · QUIC | **30,269**(131 MiB / 53.9% CPU) | **29,544**(130 MiB / 52.9% CPU) | **1×** | **0.98×** |
+| HTTP/3 · QUIC | HTTP/1 · plain | **13,942**(113 MiB / 45.5% CPU) | **13,712**(111 MiB / 46.6% CPU) | **0.97×** | **0.95×** |
+| HTTP/3 · QUIC | HTTP/1 · TLS | **16,217**(120 MiB / 44% CPU) | **15,113**(124 MiB / 44.8% CPU) | **0.92×** | **0.86×** |
+| HTTP/3 · QUIC | HTTP/2 · plain | **31,662**(139 MiB / 51.8% CPU) | **29,770**(139 MiB / 50.7% CPU) | **0.93×** | **0.87×** |
+| HTTP/3 · QUIC | HTTP/2 · TLS | **28,483**(130 MiB / 49.9% CPU) | **27,720**(133 MiB / 48.2% CPU) | **0.91×** | **0.89×** |
+| HTTP/3 · QUIC | HTTP/3 · QUIC | **17,584**(123 MiB / 45.2% CPU) | **23,082**(123 MiB / 46.4% CPU) | **1.01×** | **1.33×** |
## Linux — Titanium vs nginx vs HAProxy vs Envoy vs YARP
### Reverse
-Median of **3 repeats** on `ubuntu-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `9a2b3a1e` — `compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. **Linux nginx is the authoritative nginx baseline.** HAProxy (3.2 `USE_QUIC`) and Envoy (GitHub release, HTTP/3 compiled in) run on the same loopback shape as nginx/YARP. nginx terminate peers use `keepalive 256` + streaming buffers. The RPS workflow installs nginx.org mainline (`http_v3_module`), a QUIC-enabled HAProxy, Envoy, and `libmsquic`. Prefer ratios over absolute RPS. Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair).
+Median of **3 repeats** on `ubuntu-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `8bfa7852` — `compare-product` [35092827644](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092827644). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. **Linux nginx is the authoritative nginx baseline.** HAProxy (3.2 `USE_QUIC`) and Envoy (GitHub release, HTTP/3 compiled in) run on the same loopback shape as nginx/YARP. nginx terminate peers use `keepalive 256` + streaming buffers. The RPS workflow installs nginx.org mainline (`http_v3_module`), a QUIC-enabled HAProxy, Envoy, and `libmsquic`. Prefer ratios over absolute RPS. Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair).
| Client | Origin | TWP | nginx | HAProxy | Envoy | YARP |
|---|---|---:|---:|---:|---:|---:|
-| HTTP/1 · plain | HTTP/1 · plain | **36,672**(94 MiB / 50.5% CPU) | 🥇 **44,112**(76 MiB / 40.4% CPU) | **41,748**(67 MiB / 41.3% CPU) | **24,415**(116 MiB / 59.5% CPU) | **32,393**(115 MiB / 48.8% CPU) |
-| HTTP/1 · plain | HTTP/1 · TLS | **28,506**(107 MiB / 49.3% CPU) | 🥇 **34,687**(93 MiB / 40.7% CPU) | **32,839**(71 MiB / 43% CPU) | **22,383**(117 MiB / 56.4% CPU) | **25,710**(138 MiB / 49.6% CPU) |
-| HTTP/1 · plain | HTTP/2 · plain | 🥇 **45,673**(129 MiB / 52.7% CPU) | *Not possible (no H2 upstream)* | **26,830**(68 MiB / 42.8% CPU) | **27,447**(116 MiB / 61% CPU) | **40,965**(125 MiB / 49.4% CPU) |
-| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **37,934**(147 MiB / 49.1% CPU) | *Not possible (no H2 upstream)* | **35,699**(68 MiB / 43% CPU) | **19,949**(117 MiB / 61.9% CPU) | **35,520**(132 MiB / 47.3% CPU) |
-| HTTP/1 · plain | HTTP/3 · QUIC | 🥇 **26,151**(141 MiB / 53.8% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **12,126**(120 MiB / 56.3% CPU) | **24,086**(155 MiB / 48.3% CPU) |
-| HTTP/1 · TLS | HTTP/1 · plain | **27,742**(111 MiB / 48.9% CPU) | **33,232**(100 MiB / 41.5% CPU) | 🥇 **33,283**(85 MiB / 43.6% CPU) | **21,660**(127 MiB / 56.4% CPU) | **24,735**(142 MiB / 49.4% CPU) |
-| HTTP/1 · TLS | HTTP/1 · TLS | **23,878**(111 MiB / 48.2% CPU) | 🥇 **28,042**(103 MiB / 41.6% CPU) | **27,394**(86 MiB / 43.8% CPU) | **19,309**(128 MiB / 55% CPU) | **21,716**(142 MiB / 49.4% CPU) |
-| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **34,010**(140 MiB / 51.4% CPU) | *Not possible (no H2 upstream)* | **21,760**(83 MiB / 42.9% CPU) | **18,208**(127 MiB / 58.6% CPU) | **30,979**(141 MiB / 49.2% CPU) |
-| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **29,656**(157 MiB / 48.6% CPU) | *Not possible (no H2 upstream)* | **29,078**(83 MiB / 44% CPU) | **21,903**(126 MiB / 56.9% CPU) | **27,210**(146 MiB / 48.3% CPU) |
-| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **22,269**(152 MiB / 52.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **11,093**(130 MiB / 55.9% CPU) | **19,851**(166 MiB / 49.4% CPU) |
-| HTTP/2 · plain | HTTP/1 · plain | 🥇 **41,263**(126 MiB / 53.1% CPU) | **18,170**(79 MiB / 19.6% CPU) | **27,652**(69 MiB / 24.4% CPU) | **17,979**(118 MiB / 23% CPU) | **39,219**(115 MiB / 48.3% CPU) |
-| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **32,750**(131 MiB / 51% CPU) | **15,078**(100 MiB / 19.2% CPU) | **22,285**(71 MiB / 24.5% CPU) | **16,529**(119 MiB / 23.1% CPU) | **31,159**(125 MiB / 48.4% CPU) |
-| HTTP/2 · plain | HTTP/2 · plain | 🥇 **97,239**(90 MiB / 38% CPU) | *Not possible (no H2 upstream)* | **30,261**(69 MiB / 24.3% CPU) | **24,868**(116 MiB / 21.8% CPU) | **55,752**(122 MiB / 47.1% CPU) |
-| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **67,702**(93 MiB / 38.2% CPU) | *Not possible (no H2 upstream)* | **23,815**(68 MiB / 24.4% CPU) | **16,231**(118 MiB / 21.5% CPU) | **44,692**(129 MiB / 45.3% CPU) |
-| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **30,188**(150 MiB / 51.4% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **5,340**(121 MiB / 24.8% CPU) | **28,445**(156 MiB / 45.5% CPU) |
-| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **38,662**(128 MiB / 52.3% CPU) | **17,745**(100 MiB / 18.8% CPU) | **24,393**(81 MiB / 24.3% CPU) | **17,919**(128 MiB / 22.5% CPU) | **34,084**(122 MiB / 48.1% CPU) |
-| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **30,318**(119 MiB / 49.8% CPU) | **15,083**(110 MiB / 19.6% CPU) | **20,646**(85 MiB / 24.4% CPU) | **16,394**(128 MiB / 22.2% CPU) | **27,788**(131 MiB / 48.5% CPU) |
-| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **85,544**(102 MiB / 38% CPU) | *Not possible (no H2 upstream)* | **53,648**(85 MiB / 24.2% CPU) | **24,277**(126 MiB / 21.5% CPU) | **44,751**(125 MiB / 46.4% CPU) |
-| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **63,379**(99 MiB / 36% CPU) | *Not possible (no H2 upstream)* | **22,388**(84 MiB / 24.4% CPU) | **21,510**(125 MiB / 20.6% CPU) | **38,643**(128 MiB / 45.3% CPU) |
-| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **28,423**(151 MiB / 50.5% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **5,476**(129 MiB / 24.6% CPU) | **25,085**(163 MiB / 45.8% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · plain | **23,715**(149 MiB / 52.2% CPU) | **0**(peak 19,206 · 107 MiB / 21.6% CPU) | 🥇 **30,835**(86 MiB / 25.2% CPU) | **3**(130 MiB / 0.1% CPU) | **21,546**(186 MiB / 49% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **19,505**(160 MiB / 49.3% CPU) | **0**(peak 14,850 · 117 MiB / 22.7% CPU) | **18,067**(94 MiB / 29.8% CPU) | **4**(peak 3,743 · 131 MiB / 23.1% CPU) | **18,423**(197 MiB / 49.9% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **31,386**(160 MiB / 56.2% CPU) | *Not possible (no H2 upstream)* | **29,864**(86 MiB / 25.4% CPU) | **18**(peak 4,898 · 138 MiB / 24.5% CPU) | **26,924**(197 MiB / 48% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **27,669**(154 MiB / 52.3% CPU) | *Not possible (no H2 upstream)* | **32,742**(peak 32,880 · 92 MiB / 25.9% CPU) | **5**(peak 4,411 · 139 MiB / 25.0% CPU) | **24,488**(199 MiB / 47.6% CPU) |
-| HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 **21,858**(164 MiB / 47.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **2,964**(peak 3,679 · 134 MiB / 24.8% CPU) | **17,840**(209 MiB / 47% CPU) |
+| HTTP/1 · plain | HTTP/1 · plain | **52,118**(95 MiB / 48.8% CPU) | 🥇 **70,158**(76 MiB / 38.3% CPU) | **66,062**(64 MiB / 41.8% CPU) | **38,126**(116 MiB / 59.3% CPU) | **47,079**(115 MiB / 49.4% CPU) |
+| HTTP/1 · plain | HTTP/1 · TLS | **23,523**(107 MiB / 51.1% CPU) | **28,019**(92 MiB / 42.1% CPU) | 🥇 **28,193**(68 MiB / 43.6% CPU) | **17,450**(118 MiB / 59% CPU) | **21,545**(127 MiB / 50.3% CPU) |
+| HTTP/1 · plain | HTTP/2 · plain | 🥇 **40,142**(129 MiB / 51.1% CPU) | *Not possible (no H2 upstream)* | **26,857**(65 MiB / 42.4% CPU) | **22,067**(116 MiB / 63.3% CPU) | **35,268**(126 MiB / 48.8% CPU) |
+| HTTP/1 · plain | HTTP/2 · TLS | **51,676**(158 MiB / 48.4% CPU) | *Not possible (no H2 upstream)* | 🥇 **54,114**(68 MiB / 41% CPU) | **37,450**(117 MiB / 57.4% CPU) | **48,422**(131 MiB / 48.2% CPU) |
+| HTTP/1 · plain | HTTP/3 · QUIC | 🥇 **23,529**(143 MiB / 53.6% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **10,746**(120 MiB / 56.1% CPU) | **21,315**(156 MiB / 49.2% CPU) |
+| HTTP/1 · TLS | HTTP/1 · plain | **22,984**(112 MiB / 49.8% CPU) | 🥇 **27,706**(100 MiB / 41.6% CPU) | **27,584**(83 MiB / 43.2% CPU) | **17,122**(127 MiB / 58% CPU) | **20,033**(134 MiB / 50.2% CPU) |
+| HTTP/1 · TLS | HTTP/1 · TLS | **35,195**(112 MiB / 45.8% CPU) | **43,625**(105 MiB / 38.9% CPU) | 🥇 **44,407**(84 MiB / 40.7% CPU) | **30,389**(127 MiB / 53.8% CPU) | **31,410**(143 MiB / 48.3% CPU) |
+| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **27,813**(141 MiB / 50.6% CPU) | *Not possible (no H2 upstream)* | **21,585**(84 MiB / 42.3% CPU) | **18,519**(126 MiB / 58.2% CPU) | **25,000**(148 MiB / 49.7% CPU) |
+| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **24,173**(155 MiB / 48.7% CPU) | *Not possible (no H2 upstream)* | **23,923**(84 MiB / 43.3% CPU) | **17,323**(126 MiB / 57.5% CPU) | **22,008**(148 MiB / 47.6% CPU) |
+| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **26,290**(157 MiB / 51.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **17,802**(130 MiB / 52.4% CPU) | **24,262**(171 MiB / 49.8% CPU) |
+| HTTP/2 · plain | HTTP/1 · plain | 🥇 **36,694**(120 MiB / 53.3% CPU) | **16,440**(79 MiB / 19% CPU) | **23,041**(66 MiB / 24.6% CPU) | **13,601**(118 MiB / 23.2% CPU) | **34,322**(114 MiB / 50.2% CPU) |
+| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **27,336**(129 MiB / 51% CPU) | **12,859**(100 MiB / 19.6% CPU) | **18,613**(70 MiB / 24.6% CPU) | **12,985**(119 MiB / 23.4% CPU) | **25,795**(127 MiB / 51% CPU) |
+| HTTP/2 · plain | HTTP/2 · plain | 🥇 **118,021**(89 MiB / 36% CPU) | *Not possible (no H2 upstream)* | **48,169**(68 MiB / 24.2% CPU) | **25,730**(116 MiB / 21.3% CPU) | **68,950**(137 MiB / 48% CPU) |
+| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **58,227**(86 MiB / 35.1% CPU) | *Not possible (no H2 upstream)* | **19,761**(69 MiB / 24.6% CPU) | **15,929**(118 MiB / 21.6% CPU) | **39,519**(128 MiB / 45.6% CPU) |
+| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **27,742**(147 MiB / 50.5% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **4,280**(121 MiB / 24.9% CPU) | **26,149**(153 MiB / 46.4% CPU) |
+| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **49,222**(125 MiB / 49.5% CPU) | **30,623**(102 MiB / 19.1% CPU) | **40,300**(81 MiB / 24.3% CPU) | **22,680**(129 MiB / 22.7% CPU) | **45,846**(123 MiB / 47.7% CPU) |
+| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **26,240**(122 MiB / 50.9% CPU) | **12,497**(110 MiB / 19.9% CPU) | **16,768**(85 MiB / 24.4% CPU) | **13,048**(128 MiB / 23.3% CPU) | **23,132**(133 MiB / 50.3% CPU) |
+| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **75,608**(99 MiB / 36.6% CPU) | *Not possible (no H2 upstream)* | **21,385**(82 MiB / 24.4% CPU) | **17,016**(126 MiB / 22.1% CPU) | **39,430**(130 MiB / 47.6% CPU) |
+| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **81,336**(101 MiB / 34.1% CPU) | *Not possible (no H2 upstream)* | **38,308**(82 MiB / 24.2% CPU) | **24,040**(126 MiB / 21.6% CPU) | **46,396**(129 MiB / 44% CPU) |
+| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **25,960**(154 MiB / 50.3% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **4,339**(129 MiB / 24.8% CPU) | **22,717**(154 MiB / 47% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · plain | **20,471**(147 MiB / 50% CPU) | **0**(peak 15,362 · 106 MiB / 22.5% CPU) | 🥇 **23,011**(87 MiB / 26.3% CPU) | **3,989**(136 MiB / 24.8% CPU) | **18,716**(183 MiB / 50.2% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · TLS | **23,817**(165 MiB / 45.2% CPU) | **0**(peak 27,931 · 123 MiB / 20.1% CPU) | 🥇 **29,255**(91 MiB / 25.8% CPU) | **14**(136 MiB / 0.1% CPU) | **22,334**(203 MiB / 47.7% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **28,969**(158 MiB / 53% CPU) | *Not possible (no H2 upstream)* | **24,863**(86 MiB / 25.8% CPU) | **26**(130 MiB / 0.2% CPU) | **23,885**(197 MiB / 48.2% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **25,108**(157 MiB / 50.6% CPU) | *Not possible (no H2 upstream)* | **20,697**(88 MiB / 25.4% CPU) | **1,471**(132 MiB / 7.8% CPU) | **21,179**(196 MiB / 47.3% CPU) |
+| HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 **22,764**(170 MiB / 44.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **448**(130 MiB / 2.2% CPU) | **19,268**(210 MiB / 47.5% CPU) |
### MITM (TWP only)
-Same Client×Origin wires with interception on (`compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.75×** (no terminate-peer gate).
+Same Client×Origin wires with interception on (`compare-product` [35092827644](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092827644)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.75×** (no terminate-peer gate).
**v1 append-only relay (2026-08-27):** Pre-fix H2→H2 Full÷Reverse was **0.13–0.16×** ([32960766249](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32960766249)). Post-fix @ `df172718`: H2 plain→H2 plain Full **0.77–0.79×**, H3→H1 Full **0.91–0.93×**, all MITM arms ≥ **0.70×** on median of [33041445371](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33041445371), [33055267086](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055267086), [33055272140](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055272140).
-**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `9a2b3a1e`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin).
+**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `8bfa7852`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin).
| Client | Origin | Lite sustain | Full sustain | Lite÷Reverse | Full÷Reverse |
|---|---|---:|---:|---:|---:|
-| HTTP/1 · plain | HTTP/1 · plain | **35578**(88 MiB / 50.3% CPU) | **34914**(97 MiB / 50.4% CPU) | **0.97×** | **0.95×** |
-| HTTP/1 · plain | HTTP/1 · TLS | **28326**(114 MiB / 49.8% CPU) | **28227**(114 MiB / 49.8% CPU) | **0.99×** | **0.99×** |
-| HTTP/1 · plain | HTTP/2 · plain | **46274**(124 MiB / 54.5% CPU) | **44157**(135 MiB / 54.7% CPU) | **1.01×** | **0.97×** |
-| HTTP/1 · plain | HTTP/2 · TLS | **37133**(147 MiB / 51% CPU) | **36654**(146 MiB / 51.6% CPU) | **0.98×** | **0.97×** |
-| HTTP/1 · plain | HTTP/3 · QUIC | **26091**(142 MiB / 54.6% CPU) | **25394**(145 MiB / 54.4% CPU) | **1×** | **0.97×** |
-| HTTP/1 · TLS | HTTP/1 · plain | **27963**(116 MiB / 50.4% CPU) | **27273**(118 MiB / 50% CPU) | **1.01×** | **0.98×** |
-| HTTP/1 · TLS | HTTP/1 · TLS | **24331**(118 MiB / 49% CPU) | **23589**(116 MiB / 48.9% CPU) | **1.02×** | **0.99×** |
-| HTTP/1 · TLS | HTTP/2 · plain | **33674**(144 MiB / 52.4% CPU) | **32894**(144 MiB / 52% CPU) | **0.99×** | **0.97×** |
-| HTTP/1 · TLS | HTTP/2 · TLS | **29341**(163 MiB / 49.4% CPU) | **29024**(160 MiB / 50.3% CPU) | **0.99×** | **0.98×** |
-| HTTP/1 · TLS | HTTP/3 · QUIC | **21874**(159 MiB / 53.2% CPU) | **21237**(162 MiB / 53.3% CPU) | **0.98×** | **0.95×** |
-| HTTP/2 · plain | HTTP/1 · plain | **39834**(120 MiB / 54% CPU) | **38577**(118 MiB / 53.7% CPU) | **0.97×** | **0.93×** |
-| HTTP/2 · plain | HTTP/1 · TLS | **32663**(135 MiB / 52.2% CPU) | **31085**(123 MiB / 51.7% CPU) | **1×** | **0.95×** |
-| HTTP/2 · plain | HTTP/2 · plain | **73656**(93 MiB / 43.9% CPU) | **71161**(97 MiB / 42.4% CPU) | **0.76×** | **0.73×** |
-| HTTP/2 · plain | HTTP/2 · TLS | **55089**(105 MiB / 41.2% CPU) | **51723**(99 MiB / 40.6% CPU) | **0.81×** | **0.76×** |
-| HTTP/2 · plain | HTTP/3 · QUIC | **30054**(151 MiB / 51.4% CPU) | **29788**(156 MiB / 51.7% CPU) | **1×** | **0.99×** |
-| HTTP/2 · TLS | HTTP/1 · plain | **36903**(123 MiB / 53.2% CPU) | **35802**(127 MiB / 53.5% CPU) | **0.95×** | **0.93×** |
-| HTTP/2 · TLS | HTTP/1 · TLS | **30528**(130 MiB / 51.2% CPU) | **29484**(124 MiB / 50.3% CPU) | **1.01×** | **0.97×** |
-| HTTP/2 · TLS | HTTP/2 · plain | **63418**(114 MiB / 43% CPU) | **60875**(111 MiB / 42.8% CPU) | **0.74×** | **0.71×** |
-| HTTP/2 · TLS | HTTP/2 · TLS | **52660**(106 MiB / 40.8% CPU) | **49767**(106 MiB / 39.5% CPU) | **0.83×** | **0.79×** |
-| HTTP/2 · TLS | HTTP/3 · QUIC | **28301**(166 MiB / 51.1% CPU) | **27450**(154 MiB / 50.7% CPU) | **1×** | **0.97×** |
-| HTTP/3 · QUIC | HTTP/1 · plain | **22951**(150 MiB / 52.3% CPU) | **22840**(150 MiB / 52.4% CPU) | **0.97×** | **0.96×** |
-| HTTP/3 · QUIC | HTTP/1 · TLS | **18924**(165 MiB / 50.9% CPU) | **18029**(157 MiB / 49.7% CPU) | **0.97×** | **0.92×** |
-| HTTP/3 · QUIC | HTTP/2 · plain | **30229**(167 MiB / 57.6% CPU) | **29277**(163 MiB / 56.6% CPU) | **0.96×** | **0.93×** |
-| HTTP/3 · QUIC | HTTP/2 · TLS | **26262**(163 MiB / 53.8% CPU) | **25788**(168 MiB / 53.4% CPU) | **0.95×** | **0.93×** |
-| HTTP/3 · QUIC | HTTP/3 · QUIC | **20948**(163 MiB / 50.8% CPU) | **20826**(162 MiB / 50.9% CPU) | **0.96×** | **0.95×** |
+| HTTP/1 · plain | HTTP/1 · plain | **51,441**(97 MiB / 50.2% CPU) | **50,516**(98 MiB / 49.8% CPU) | **0.99×** | **0.97×** |
+| HTTP/1 · plain | HTTP/1 · TLS | **23,548**(112 MiB / 52.2% CPU) | **23,482**(113 MiB / 51.3% CPU) | **1×** | **1×** |
+| HTTP/1 · plain | HTTP/2 · plain | **40,450**(132 MiB / 53.8% CPU) | **38,305**(126 MiB / 53% CPU) | **1.01×** | **0.95×** |
+| HTTP/1 · plain | HTTP/2 · TLS | **50,652**(153 MiB / 50.5% CPU) | **49,728**(154 MiB / 50.8% CPU) | **0.98×** | **0.96×** |
+| HTTP/1 · plain | HTTP/3 · QUIC | **22,774**(142 MiB / 54.2% CPU) | **22,822**(137 MiB / 54.3% CPU) | **0.97×** | **0.97×** |
+| HTTP/1 · TLS | HTTP/1 · plain | **23,114**(115 MiB / 50.9% CPU) | **22,808**(116 MiB / 50.3% CPU) | **1.01×** | **0.99×** |
+| HTTP/1 · TLS | HTTP/1 · TLS | **34,966**(117 MiB / 46.7% CPU) | **34,270**(118 MiB / 46.8% CPU) | **0.99×** | **0.97×** |
+| HTTP/1 · TLS | HTTP/2 · plain | **27,488**(143 MiB / 51.3% CPU) | **26,553**(148 MiB / 51.2% CPU) | **0.99×** | **0.95×** |
+| HTTP/1 · TLS | HTTP/2 · TLS | **23,559**(170 MiB / 48.9% CPU) | **23,089**(169 MiB / 48.7% CPU) | **0.97×** | **0.96×** |
+| HTTP/1 · TLS | HTTP/3 · QUIC | **25,937**(156 MiB / 52.5% CPU) | **26,697**(161 MiB / 53.2% CPU) | **0.99×** | **1.02×** |
+| HTTP/2 · plain | HTTP/1 · plain | **36,272**(120 MiB / 54.6% CPU) | **34,343**(120 MiB / 54.4% CPU) | **0.99×** | **0.94×** |
+| HTTP/2 · plain | HTTP/1 · TLS | **27,164**(124 MiB / 52.4% CPU) | **26,445**(129 MiB / 52.2% CPU) | **0.99×** | **0.97×** |
+| HTTP/2 · plain | HTTP/2 · plain | **88,034**(101 MiB / 40.5% CPU) | **81,934**(98 MiB / 40.2% CPU) | **0.75×** | **0.69×** |
+| HTTP/2 · plain | HTTP/2 · TLS | **48,275**(95 MiB / 40.2% CPU) | **45,097**(98 MiB / 40.2% CPU) | **0.83×** | **0.77×** |
+| HTTP/2 · plain | HTTP/3 · QUIC | **27,595**(143 MiB / 50.5% CPU) | **27,350**(147 MiB / 51.1% CPU) | **0.99×** | **0.99×** |
+| HTTP/2 · TLS | HTTP/1 · plain | **48,535**(132 MiB / 50.6% CPU) | **46,791**(134 MiB / 50% CPU) | **0.99×** | **0.95×** |
+| HTTP/2 · TLS | HTTP/1 · TLS | **25,779**(123 MiB / 51.8% CPU) | **25,125**(129 MiB / 52.1% CPU) | **0.98×** | **0.96×** |
+| HTTP/2 · TLS | HTTP/2 · plain | **55,993**(110 MiB / 41.3% CPU) | **53,218**(114 MiB / 41% CPU) | **0.74×** | **0.7×** |
+| HTTP/2 · TLS | HTTP/2 · TLS | **64,140**(113 MiB / 36.2% CPU) | **60,738**(112 MiB / 36.2% CPU) | **0.79×** | **0.75×** |
+| HTTP/2 · TLS | HTTP/3 · QUIC | **26,305**(160 MiB / 51.5% CPU) | **25,709**(152 MiB / 50.6% CPU) | **1.01×** | **0.99×** |
+| HTTP/3 · QUIC | HTTP/1 · plain | **19,415**(144 MiB / 50.9% CPU) | **19,586**(145 MiB / 50.1% CPU) | **0.95×** | **0.96×** |
+| HTTP/3 · QUIC | HTTP/1 · TLS | **23,253**(171 MiB / 47.3% CPU) | **22,767**(170 MiB / 46% CPU) | **0.98×** | **0.96×** |
+| HTTP/3 · QUIC | HTTP/2 · plain | **27,212**(160 MiB / 54.2% CPU) | **26,934**(163 MiB / 54.5% CPU) | **0.94×** | **0.93×** |
+| HTTP/3 · QUIC | HTTP/2 · TLS | **23,551**(152 MiB / 51.9% CPU) | **22,624**(153 MiB / 51.9% CPU) | **0.94×** | **0.9×** |
+| HTTP/3 · QUIC | HTTP/3 · QUIC | **21,049**(171 MiB / 48.3% CPU) | **20,830**(171 MiB / 48.2% CPU) | **0.92×** | **0.92×** |
## macOS — Titanium vs nginx vs HAProxy vs Envoy vs YARP
### Reverse
-Median of **3 repeats** on `macos-15-intel` (4-core / 14 GB). Bare reverse 5×5 @ `9a2b3a1e` — `compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151); Envoy H3 inbound remainder @ `a495a9ae` — [34557758404](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34557758404)–[34557765742](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34557765742). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** show sustain RPS; `` holds peak (when higher) plus median RSS / CPU at the peak-RPS step. The RPS workflow installs Homebrew nginx (`http_v3_module`), Homebrew HAProxy with `USE_QUIC` (3.2 source fallback), Envoy (Homebrew bottle or pinned darwin-amd64 1.36.7), Homebrew `libmsquic` (+ `DYLD_*`), and YARP. Do not publish from `macos-latest` (3-core / 7 GB). Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair).
+Median of **3 repeats** on `macos-15-intel` (4-core / 14 GB). Bare reverse 5×5 @ `8bfa7852` — `compare-product` [35092827644](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092827644). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as `(MiB / CPU%) `. The RPS workflow installs Homebrew nginx (`http_v3_module`), Homebrew HAProxy with `USE_QUIC` (3.2 source fallback), Envoy (Homebrew bottle or pinned darwin-amd64 1.36.7), Homebrew `libmsquic` (+ `DYLD_*`), and YARP. Do not publish from `macos-latest` (3-core / 7 GB). Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair).
| Client | Origin | TWP | nginx | HAProxy | Envoy | YARP |
|---|---|---:|---:|---:|---:|---:|
-| HTTP/1 · plain | HTTP/1 · plain | 🥇 **12,131**(82 MiB / 32.8% CPU) | **6,938**(52 MiB / 11.6% CPU) | **10,945**(48 MiB / 23.2% CPU) | **3,332**(72 MiB / 22.4% CPU) | **10,805**(105 MiB / 33.6% CPU) |
-| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **11,064**(93 MiB / 36.5% CPU) | **5,152**(73 MiB / 17.4% CPU) | **7,159**(53 MiB / 25.9% CPU) | **0**(peak 2,011 · 75 MiB / 21% CPU) | **9,146**(129 MiB / 36.8% CPU) |
-| HTTP/1 · plain | HTTP/2 · plain | 🥇 **19,185**(88 MiB / 33.8% CPU) | *Not possible (no H2 upstream)* | **11,853**(50 MiB / 25.6% CPU) | **6,000**(73 MiB / 36.6% CPU) | **18,729**(111 MiB / 34% CPU) |
-| HTTP/1 · plain | HTTP/2 · TLS | **9,927**(129 MiB / 32.9% CPU) | *Not possible (no H2 upstream)* | **8,770**(51 MiB / 26.5% CPU) | **5,948**(73 MiB / 34.9% CPU) | 🥇 **13,674**(116 MiB / 32.9% CPU) |
-| HTTP/1 · plain | HTTP/3 · QUIC | **3,662**(90 MiB / 45.3% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **3,393**(75 MiB / 38.2% CPU) | 🥇 **5,774**(116 MiB / 33.4% CPU) |
-| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **14,162**(94 MiB / 31.5% CPU) | **8,184**(74 MiB / 18.3% CPU) | **9,938**(64 MiB / 25.6% CPU) | **4,084**(peak 4,328 · 79 MiB / 37.4% CPU) | **7,647**(115 MiB / 32.3% CPU) |
-| HTTP/1 · TLS | HTTP/1 · TLS | **6,128**(97 MiB / 31.7% CPU) | **4,903**(81 MiB / 15.5% CPU) | 🥇 **9,241**(67 MiB / 28% CPU) | **3,594**(80 MiB / 38.9% CPU) | **6,601**(172 MiB / 35% CPU) |
-| HTTP/1 · TLS | HTTP/2 · plain | **8,317**(96 MiB / 32.8% CPU) | *Not possible (no H2 upstream)* | 🥇 **12,353**(66 MiB / 28% CPU) | **5,378**(80 MiB / 20.9% CPU) | **10,735**(122 MiB / 32.2% CPU) |
-| HTTP/1 · TLS | HTTP/2 · TLS | **8,457**(161 MiB / 33.8% CPU) | *Not possible (no H2 upstream)* | **8,558**(65 MiB / 29.3% CPU) | **4,938**(peak 5,195 · 79 MiB / 39.8% CPU) | 🥇 **9,023**(120 MiB / 28.7% CPU) |
-| HTTP/1 · TLS | HTTP/3 · QUIC | **2,677**(112 MiB / 44.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | 🥇 **4,392**(81 MiB / 37.8% CPU) | **3,589**(152 MiB / 31.7% CPU) |
-| HTTP/2 · plain | HTTP/1 · plain | **12,398**(88 MiB / 35.1% CPU) | **10,718**(58 MiB / 15.4% CPU) | **7,841**(54 MiB / 18% CPU) | **3,391**(74 MiB / 20.7% CPU) | 🥇 **12,934**(105 MiB / 37.8% CPU) |
-| HTTP/2 · plain | HTTP/1 · TLS | **13,814**(94 MiB / 38.6% CPU) | **12,186**(85 MiB / 16% CPU) | **6,688**(56 MiB / 18.9% CPU) | **5,045**(77 MiB / 21.3% CPU) | 🥇 **14,337**(121 MiB / 42.3% CPU) |
-| HTTP/2 · plain | HTTP/2 · plain | 🥇 **30,992**(73 MiB / 25.8% CPU) | *Not possible (no H2 upstream)* | **8,615**(54 MiB / 17.5% CPU) | **5,442**(72 MiB / 20% CPU) | **22,898**(109 MiB / 37.5% CPU) |
-| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **24,573**(77 MiB / 27.7% CPU) | *Not possible (no H2 upstream)* | **11,876**(58 MiB / 19% CPU) | **6,393**(73 MiB / 20.2% CPU) | **18,700**(116 MiB / 34.6% CPU) |
-| HTTP/2 · plain | HTTP/3 · QUIC | **4,840**(97 MiB / 49.3% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **3,760**(74 MiB / 22.4% CPU) | 🥇 **8,092**(117 MiB / 33.9% CPU) |
-| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **13,829**(94 MiB / 39.2% CPU) | **9,070**(73 MiB / 14.5% CPU) | **5,237**(66 MiB / 16.1% CPU) | **3,516**(81 MiB / 20.4% CPU) | **13,572**(113 MiB / 37.3% CPU) |
-| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **9,182**(96 MiB / 37.3% CPU) | **6,812**(90 MiB / 15.3% CPU) | **4,920**(66 MiB / 18.2% CPU) | **2,505**(83 MiB / 20.1% CPU) | **9,080**(124 MiB / 40.6% CPU) |
-| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **47,325**(81 MiB / 29% CPU) | *Not possible (no H2 upstream)* | **5,750**(67 MiB / 5.9% CPU) | **4,704**(79 MiB / 19% CPU) | **22,095**(115 MiB / 37.6% CPU) |
-| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **29,038**(83 MiB / 27.1% CPU) | *Not possible (no H2 upstream)* | **6,022**(67 MiB / 18.3% CPU) | **4,494**(79 MiB / 18.6% CPU) | **16,458**(118 MiB / 35% CPU) |
-| HTTP/2 · TLS | HTTP/3 · QUIC | **4,226**(108 MiB / 36% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **1,772**(81 MiB / 21.6% CPU) | 🥇 **4,805**(125 MiB / 33.7% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · plain | **5,834**(101 MiB / 39.5% CPU) | **0**(peak 7,464 · 63 MiB / 11.2% CPU) | 🥇 **10,727**(66 MiB / 22% CPU) | **1,595**(85 MiB / 26.4% CPU) | **5,558**(187 MiB / 35% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · TLS | **3,723**(119 MiB / 38.2% CPU) | **0**(peak 4,316 · 66 MiB / 11.1% CPU) | 🥇 **5,430**(69 MiB / 21.8% CPU) | **1,893**(89 MiB / 31.2% CPU) | **4,786**(197 MiB / 35.5% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · plain | **4,576**(97 MiB / 41.3% CPU) | *Not possible (no H2 upstream)* | 🥇 **5,954**(66 MiB / 14.4% CPU) | **1,262**(83 MiB / 25.3% CPU) | **5,642**(176 MiB / 34.2% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · TLS | **5,532**(106 MiB / 41.4% CPU) | *Not possible (no H2 upstream)* | **6,633**(68 MiB / 21.5% CPU) | **2,692**(84 MiB / 32.1% CPU) | 🥇 **7,854**(167 MiB / 32.5% CPU) |
-| HTTP/3 · QUIC | HTTP/3 · QUIC | **3,460**(96 MiB / 35% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **1,036**(peak 1,116 · 82 MiB / 28.4% CPU) | 🥇 **4,659**(188 MiB / 34.4% CPU) |
+| HTTP/1 · plain | HTTP/1 · plain | 🥇 **16,348**(84 MiB / 39.4% CPU) | **9,898**(52 MiB / 18.6% CPU) | **15,804**(50 MiB / 31.4% CPU) | **6,117**(72 MiB / 41.8% CPU) | **13,286**(104 MiB / 41.2% CPU) |
+| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **12,783**(94 MiB / 43.1% CPU) | **5,967**(74 MiB / 18.6% CPU) | **12,525**(55 MiB / 33.2% CPU) | **5,670**(73 MiB / 48% CPU) | **9,909**(124 MiB / 40.9% CPU) |
+| HTTP/1 · plain | HTTP/2 · plain | 🥇 **20,904**(88 MiB / 43.3% CPU) | *Not possible (no H2 upstream)* | **8,778**(51 MiB / 30.6% CPU) | **6,122**(71 MiB / 44.3% CPU) | **15,420**(112 MiB / 40.6% CPU) |
+| HTTP/1 · plain | HTTP/2 · TLS | **13,267**(101 MiB / 37.9% CPU) | *Not possible (no H2 upstream)* | **12,220**(52 MiB / 31.2% CPU) | **7,310**(73 MiB / 48.7% CPU) | 🥇 **18,474**(116 MiB / 41.3% CPU) |
+| HTTP/1 · plain | HTTP/3 · QUIC | **4,693**(89 MiB / 48.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **3,091**(75 MiB / 23.7% CPU) | 🥇 **5,445**(115 MiB / 38.5% CPU) |
+| HTTP/1 · TLS | HTTP/1 · plain | **7,955**(94 MiB / 34.9% CPU) | **6,371**(74 MiB / 25.2% CPU) | **7,738**(65 MiB / 31.1% CPU) | **6,444**(80 MiB / 41.2% CPU) | 🥇 **8,665**(132 MiB / 38.8% CPU) |
+| HTTP/1 · TLS | HTTP/1 · TLS | **8,977**(98 MiB / 36.1% CPU) | **5,494**(83 MiB / 18.9% CPU) | 🥇 **9,125**(68 MiB / 35.1% CPU) | **3,695**(81 MiB / 37.8% CPU) | **7,820**(139 MiB / 37.4% CPU) |
+| HTTP/1 · TLS | HTTP/2 · plain | **9,860**(97 MiB / 39.1% CPU) | *Not possible (no H2 upstream)* | **7,800**(66 MiB / 32.1% CPU) | **5,521**(79 MiB / 45.2% CPU) | 🥇 **10,651**(118 MiB / 34.5% CPU) |
+| HTTP/1 · TLS | HTTP/2 · TLS | **7,860**(163 MiB / 37% CPU) | *Not possible (no H2 upstream)* | **7,448**(66 MiB / 35.7% CPU) | **4,624**(79 MiB / 40.9% CPU) | 🥇 **11,054**(127 MiB / 34.9% CPU) |
+| HTTP/1 · TLS | HTTP/3 · QUIC | **2,705**(109 MiB / 46.2% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **3,636**(81 MiB / 32.3% CPU) | 🥇 **4,619**(123 MiB / 35.2% CPU) |
+| HTTP/2 · plain | HTTP/1 · plain | 🥇 **16,372**(91 MiB / 42.4% CPU) | **15,429**(58 MiB / 18.7% CPU) | **10,220**(56 MiB / 20.3% CPU) | **3,692**(75 MiB / 22.4% CPU) | **14,817**(105 MiB / 44.2% CPU) |
+| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **12,382**(94 MiB / 46.4% CPU) | **7,196**(85 MiB / 18.3% CPU) | **5,558**(59 MiB / 20.8% CPU) | **3,155**(77 MiB / 21.5% CPU) | **12,044**(125 MiB / 45.8% CPU) |
+| HTTP/2 · plain | HTTP/2 · plain | 🥇 **36,700**(73 MiB / 30.8% CPU) | *Not possible (no H2 upstream)* | **9,528**(57 MiB / 19.9% CPU) | **6,036**(72 MiB / 22% CPU) | **24,939**(111 MiB / 40.4% CPU) |
+| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **30,656**(77 MiB / 30.9% CPU) | *Not possible (no H2 upstream)* | **8,666**(58 MiB / 20.9% CPU) | **5,469**(73 MiB / 21.3% CPU) | **26,491**(114 MiB / 38.9% CPU) |
+| HTTP/2 · plain | HTTP/3 · QUIC | **5,092**(95 MiB / 56.1% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **1,973**(75 MiB / 22.8% CPU) | 🥇 **5,453**(118 MiB / 39.1% CPU) |
+| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **15,856**(92 MiB / 44.7% CPU) | **9,770**(73 MiB / 18.1% CPU) | **8,427**(67 MiB / 21.2% CPU) | **3,491**(81 MiB / 21.4% CPU) | **12,707**(115 MiB / 43.2% CPU) |
+| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **13,319**(96 MiB / 44.8% CPU) | **6,541**(91 MiB / 19% CPU) | **8,793**(69 MiB / 21.4% CPU) | **3,229**(83 MiB / 21.7% CPU) | **11,692**(123 MiB / 44.6% CPU) |
+| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **27,511**(84 MiB / 31.1% CPU) | *Not possible (no H2 upstream)* | **8,259**(68 MiB / 20.9% CPU) | **5,966**(79 MiB / 21.7% CPU) | **19,566**(112 MiB / 42.1% CPU) |
+| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **30,544**(84 MiB / 31.1% CPU) | *Not possible (no H2 upstream)* | **7,040**(68 MiB / 21.3% CPU) | **5,660**(79 MiB / 21.4% CPU) | **19,845**(116 MiB / 40.9% CPU) |
+| HTTP/2 · TLS | HTTP/3 · QUIC | **5,713**(106 MiB / 45.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **2,513**(80 MiB / 22.9% CPU) | 🥇 **7,097**(121 MiB / 40.4% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · plain | **4,598**(100 MiB / 46.2% CPU) | **0**(peak 6,020 · 62 MiB / 13.4% CPU) | 🥇 **9,237**(67 MiB / 23.9% CPU) | **1,238**(86 MiB / 23.4% CPU) | **4,568**(183 MiB / 39% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · TLS | **4,263**(117 MiB / 43.4% CPU) | **0**(peak 7,101 · 67 MiB / 16.4% CPU) | 🥇 **8,098**(69 MiB / 27.4% CPU) | **1,138**(87 MiB / 27.8% CPU) | **5,599**(204 MiB / 41.9% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · plain | **6,267**(97 MiB / 45% CPU) | *Not possible (no H2 upstream)* | 🥇 **8,644**(67 MiB / 21.8% CPU) | **2,182**(83 MiB / 31.8% CPU) | **6,180**(179 MiB / 38.4% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · TLS | **5,881**(107 MiB / 46% CPU) | *Not possible (no H2 upstream)* | 🥇 **8,089**(69 MiB / 24.6% CPU) | **2,252**(84 MiB / 30.9% CPU) | **7,557**(171 MiB / 39.6% CPU) |
+| HTTP/3 · QUIC | HTTP/3 · QUIC | **3,988**(97 MiB / 39.4% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | **904**(81 MiB / 27.3% CPU) | 🥇 **4,700**(182 MiB / 40.8% CPU) |
### MITM (TWP only)
-Same Client×Origin wires with interception on (`compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.75×** (no terminate-peer gate).
+Same Client×Origin wires with interception on (`compare-product` [35092827644](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092827644)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.75×** (no terminate-peer gate).
**v1 append-only relay (2026-08-27):** Pre-fix H2→H2 Full÷Reverse was **0.13–0.16×** ([32960766249](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32960766249)). Post-fix @ `df172718`: H2 plain→H2 plain Full **0.77–0.79×**, H3→H1 Full **0.91–0.93×**, all MITM arms ≥ **0.70×** on median of [33041445371](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33041445371), [33055267086](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055267086), [33055272140](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055272140).
-**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `9a2b3a1e`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin).
+**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `8bfa7852`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin).
| Client | Origin | Lite sustain | Full sustain | Lite÷Reverse | Full÷Reverse |
|---|---|---:|---:|---:|---:|
-| HTTP/1 · plain | HTTP/1 · plain | **11715**(84 MiB / 35.6% CPU) | **11001**(84 MiB / 31.8% CPU) | **0.97×** | **0.91×** |
-| HTTP/1 · plain | HTTP/1 · TLS | **7193**(94 MiB / 31.1% CPU) | **8678**(94 MiB / 35.3% CPU) | **0.65×** | **0.78×** |
-| HTTP/1 · plain | HTTP/2 · plain | **14866**(89 MiB / 36.3% CPU) | **13716**(90 MiB / 35.7% CPU) | **0.77×** | **0.71×** |
-| HTTP/1 · plain | HTTP/2 · TLS | **11164**(109 MiB / 33.4% CPU) | **9186**(108 MiB / 32.6% CPU) | **1.12×** | **0.93×** |
-| HTTP/1 · plain | HTTP/3 · QUIC | **3459**(91 MiB / 39.6% CPU) | **3710**(90 MiB / 40.3% CPU) | **0.94×** | **1.01×** |
-| HTTP/1 · TLS | HTTP/1 · plain | **8052**(95 MiB / 30.3% CPU) | **10123**(96 MiB / 33.1% CPU) | **0.57×** | **0.71×** |
-| HTTP/1 · TLS | HTTP/1 · TLS | **6914**(98 MiB / 36% CPU) | **9190**(98 MiB / 33.3% CPU) | **1.13×** | **1.5×** |
-| HTTP/1 · TLS | HTTP/2 · plain | **8443**(123 MiB / 35.7% CPU) | **8055**(102 MiB / 33.1% CPU) | **1.02×** | **0.97×** |
-| HTTP/1 · TLS | HTTP/2 · TLS | **8738**(133 MiB / 33.2% CPU) | **7260**(157 MiB / 32.6% CPU) | **1.03×** | **0.86×** |
-| HTTP/1 · TLS | HTTP/3 · QUIC | **2633**(105 MiB / 42.6% CPU) | **2842**(102 MiB / 46.3% CPU) | **0.98×** | **1.06×** |
-| HTTP/2 · plain | HTTP/1 · plain | **13742**(88 MiB / 38.4% CPU) | **13895**(90 MiB / 37.5% CPU) | **1.11×** | **1.12×** |
-| HTTP/2 · plain | HTTP/1 · TLS | **14725**(96 MiB / 40.8% CPU) | **10701**(96 MiB / 35.5% CPU) | **1.07×** | **0.77×** |
-| HTTP/2 · plain | HTTP/2 · plain | **24126**(76 MiB / 29.9% CPU) | **24457**(76 MiB / 29.7% CPU) | **0.78×** | **0.79×** |
-| HTTP/2 · plain | HTTP/2 · TLS | **23664**(83 MiB / 31.7% CPU) | **24893**(81 MiB / 31% CPU) | **0.96×** | **1.01×** |
-| HTTP/2 · plain | HTTP/3 · QUIC | **4822**(96 MiB / 45% CPU) | **5035**(95 MiB / 45.9% CPU) | **1×** | **1.04×** |
-| HTTP/2 · TLS | HTTP/1 · plain | **13114**(92 MiB / 38.3% CPU) | **12954**(91 MiB / 38.1% CPU) | **0.95×** | **0.94×** |
-| HTTP/2 · TLS | HTTP/1 · TLS | **9205**(97 MiB / 36% CPU) | **9788**(97 MiB / 39.1% CPU) | **1×** | **1.07×** |
-| HTTP/2 · TLS | HTTP/2 · plain | **28081**(86 MiB / 32% CPU) | **24916**(87 MiB / 30.4% CPU) | **0.59×** | **0.53×** |
-| HTTP/2 · TLS | HTTP/2 · TLS | **22874**(87 MiB / 29.6% CPU) | **29489**(89 MiB / 32% CPU) | **0.79×** | **1.02×** |
-| HTTP/2 · TLS | HTTP/3 · QUIC | **4679**(105 MiB / 37.9% CPU) | **4453**(106 MiB / 39.3% CPU) | **1.11×** | **1.05×** |
-| HTTP/3 · QUIC | HTTP/1 · plain | **4000**(102 MiB / 35% CPU) | **4530**(102 MiB / 38.8% CPU) | **0.69×** | **0.78×** |
-| HTTP/3 · QUIC | HTTP/1 · TLS | **3931**(116 MiB / 38.3% CPU) | **3992**(115 MiB / 40% CPU) | **1.06×** | **1.07×** |
-| HTTP/3 · QUIC | HTTP/2 · plain | **4272**(98 MiB / 39% CPU) | **4718**(98 MiB / 39.8% CPU) | **0.93×** | **1.03×** |
-| HTTP/3 · QUIC | HTTP/2 · TLS | **5183**(106 MiB / 38.5% CPU) | **4838**(108 MiB / 38.9% CPU) | **0.94×** | **0.87×** |
-| HTTP/3 · QUIC | HTTP/3 · QUIC | **3211**(94 MiB / 37.3% CPU) | **3887**(95 MiB / 37.3% CPU) | **0.93×** | **1.12×** |
+| HTTP/1 · plain | HTTP/1 · plain | **13,180**(84 MiB / 39.4% CPU) | **13,546**(85 MiB / 38.2% CPU) | **0.81×** | **0.83×** |
+| HTTP/1 · plain | HTTP/1 · TLS | **11,128**(95 MiB / 40% CPU) | **10,009**(94 MiB / 37.9% CPU) | **0.87×** | **0.78×** |
+| HTTP/1 · plain | HTTP/2 · plain | **18,046**(91 MiB / 44.7% CPU) | **12,264**(93 MiB / 43.8% CPU) | **0.86×** | **0.59×** |
+| HTTP/1 · plain | HTTP/2 · TLS | **12,795**(106 MiB / 40.2% CPU) | **17,562**(106 MiB / 42.6% CPU) | **0.96×** | **1.32×** |
+| HTTP/1 · plain | HTTP/3 · QUIC | **4,402**(91 MiB / 47.8% CPU) | **4,047**(93 MiB / 45.6% CPU) | **0.94×** | **0.86×** |
+| HTTP/1 · TLS | HTTP/1 · plain | **11,591**(95 MiB / 39.4% CPU) | **7,671**(96 MiB / 38.9% CPU) | **1.46×** | **0.96×** |
+| HTTP/1 · TLS | HTTP/1 · TLS | **8,412**(99 MiB / 37.9% CPU) | **10,725**(99 MiB / 38.9% CPU) | **0.94×** | **1.19×** |
+| HTTP/1 · TLS | HTTP/2 · plain | **10,748**(100 MiB / 41.7% CPU) | **9,978**(105 MiB / 39.5% CPU) | **1.09×** | **1.01×** |
+| HTTP/1 · TLS | HTTP/2 · TLS | **11,660**(161 MiB / 39.5% CPU) | **6,751**(156 MiB / 37.8% CPU) | **1.48×** | **0.86×** |
+| HTTP/1 · TLS | HTTP/3 · QUIC | **2,975**(98 MiB / 54.7% CPU) | **3,009**(103 MiB / 49.8% CPU) | **1.1×** | **1.11×** |
+| HTTP/2 · plain | HTTP/1 · plain | **16,657**(90 MiB / 43.4% CPU) | **16,350**(90 MiB / 44.4% CPU) | **1.02×** | **1×** |
+| HTTP/2 · plain | HTTP/1 · TLS | **14,896**(98 MiB / 46.9% CPU) | **11,821**(97 MiB / 47% CPU) | **1.2×** | **0.95×** |
+| HTTP/2 · plain | HTTP/2 · plain | **28,786**(79 MiB / 34.8% CPU) | **29,833**(76 MiB / 36.4% CPU) | **0.78×** | **0.81×** |
+| HTTP/2 · plain | HTTP/2 · TLS | **26,546**(80 MiB / 38.2% CPU) | **34,124**(83 MiB / 36.4% CPU) | **0.87×** | **1.11×** |
+| HTTP/2 · plain | HTTP/3 · QUIC | **4,078**(95 MiB / 52.4% CPU) | **4,254**(94 MiB / 53.3% CPU) | **0.8×** | **0.84×** |
+| HTTP/2 · TLS | HTTP/1 · plain | **15,130**(95 MiB / 48.3% CPU) | **15,199**(92 MiB / 45.1% CPU) | **0.95×** | **0.96×** |
+| HTTP/2 · TLS | HTTP/1 · TLS | **15,237**(100 MiB / 41.4% CPU) | **13,176**(97 MiB / 47% CPU) | **1.14×** | **0.99×** |
+| HTTP/2 · TLS | HTTP/2 · plain | **26,045**(87 MiB / 38% CPU) | **31,311**(86 MiB / 36.2% CPU) | **0.95×** | **1.14×** |
+| HTTP/2 · TLS | HTTP/2 · TLS | **29,113**(89 MiB / 36.4% CPU) | **28,038**(90 MiB / 38.6% CPU) | **0.95×** | **0.92×** |
+| HTTP/2 · TLS | HTTP/3 · QUIC | **5,228**(107 MiB / 46.1% CPU) | **3,875**(107 MiB / 45.1% CPU) | **0.92×** | **0.68×** |
+| HTTP/3 · QUIC | HTTP/1 · plain | **4,428**(101 MiB / 45.8% CPU) | **5,675**(102 MiB / 47.5% CPU) | **0.96×** | **1.23×** |
+| HTTP/3 · QUIC | HTTP/1 · TLS | **4,527**(116 MiB / 46.2% CPU) | **4,613**(116 MiB / 44.1% CPU) | **1.06×** | **1.08×** |
+| HTTP/3 · QUIC | HTTP/2 · plain | **5,291**(99 MiB / 49.4% CPU) | **4,946**(99 MiB / 48.2% CPU) | **0.84×** | **0.79×** |
+| HTTP/3 · QUIC | HTTP/2 · TLS | **4,271**(105 MiB / 46.1% CPU) | **4,538**(107 MiB / 48.8% CPU) | **0.73×** | **0.77×** |
+| HTTP/3 · QUIC | HTTP/3 · QUIC | **3,764**(95 MiB / 57.2% CPU) | **4,233**(93 MiB / 41.9% CPU) | **0.94×** | **1.06×** |
## Editions (CLI / Plus / Intercept)
@@ -445,125 +443,135 @@ Lossy link = **userspace** delay/drop shim (not kernel `netem`): TCP gets per-bu
### Windows — heavier reverse GET (64 KiB / 256 KiB)
-Median of **3** repeats on `windows-latest` @ `9a2b3a1e`. Source: Actions [34441570199](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441570199) (`compare-bodies`). Warmup 2s / measure 8s. **RPS cells** show sustain; `` holds peak (when higher) plus `(MiB / CPU%)`.
+Median of **3** repeats on `windows-latest` @ `8bfa7852`. Source: Actions [35092832912](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092832912) (`compare-bodies`). Warmup 2s / measure 8s. **RPS cells** include `(MiB / CPU%)` footprints.
+
+*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
+
+*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
| Body | Client | Origin | TWP | nginx | YARP |
|---|---|---|---:|---:|---:|
-| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **13,191**(122 MiB / 46.7% CPU) | **924**(142 MiB / 24.8% CPU) | **11,473**(133 MiB / 47.5% CPU) |
-| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **11,769**(173 MiB / 46.2% CPU) | **898**(142 MiB / 24.8% CPU) | **9,560**(135 MiB / 48.1% CPU) |
-| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **5,958**(139 MiB / 41.5% CPU) | *Not possible (no QUIC)* | **5,060**(185 MiB / 51.0% CPU) |
-| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **3,693**(136 MiB / 42.1% CPU) | **241**(142 MiB / 24.9% CPU) | **3,325**(130 MiB / 47.2% CPU) |
-| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,784**(149 MiB / 36.2% CPU) | **230**(142 MiB / 24.8% CPU) | **2,625**(135 MiB / 45.2% CPU) |
-| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,535**(111 MiB / 40.4% CPU) | *Not possible (no QUIC)* | **1,385**(169 MiB / 44.6% CPU) |
-| 64 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **14,010**(174 MiB / 40.5% CPU) | **5,865**(127 MiB / 24.0% CPU) | **12,443**(111 MiB / 39.6% CPU) |
-| 64 KiB | HTTP/2 · TLS | HTTP/2 · plain | **6,319**(79 MiB / 38.1% CPU) | *Not possible* | 🥇 **9,493**(131 MiB / 50.3% CPU) |
-| 64 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **5,660**(80 MiB / 36.4% CPU) | *Not possible* | 🥇 **8,201**(141 MiB / 47.6% CPU) |
-| 64 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **4,657**(129 MiB / 46.1% CPU) | *Not possible* | 🥇 **5,021**(195 MiB / 47.6% CPU) |
-| 64 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **6,149**(148 MiB / 42.2% CPU) | *Not possible (no QUIC)* | **4,496**(191 MiB / 49.0% CPU) |
-| 256 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **4,457**(144 MiB / 31.8% CPU) | **1,759**(127 MiB / 23.6% CPU) | **3,719**(123 MiB / 37.8% CPU) |
-| 256 KiB | HTTP/2 · TLS | HTTP/2 · plain | **1,976**(84 MiB / 29.9% CPU) | *Not possible* | 🥇 **2,325**(169 MiB / 46.5% CPU) |
-| 256 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **1,758**(87 MiB / 29.7% CPU) | *Not possible* | 🥇 **2,109**(153 MiB / 44.9% CPU) |
-| 256 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **1,164**(153 MiB / 43.0% CPU) | *Not possible* | 🥇 **1,319**(186 MiB / 44.8% CPU) |
-| 256 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,404**(146 MiB / 41.0% CPU) | *Not possible (no QUIC)* | **1,269**(196 MiB / 44.8% CPU) |
+| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **8,782**(114 MiB / 46.0% CPU) | **644**(142 MiB / 24.7% CPU) | **7,764**(135 MiB / 47.3% CPU) |
+| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **8,184**(174 MiB / 46.0% CPU) | **564**(142 MiB / 24.8% CPU) | **7,013**(135 MiB / 50.5% CPU) |
+| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **3,876**(132 MiB / 38.6% CPU) | *Not possible (no QUIC)* | **3,663**(188 MiB / 46.8% CPU) |
+| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **2,790**(129 MiB / 47.6% CPU) | **0**(peak 162 · 143 MiB / 24.8% CPU) | **2,586**(135 MiB / 48.0% CPU) |
+| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **2,537**(153 MiB / 40.3% CPU) | **0**(peak 142 · 142 MiB / 24.7% CPU) | **1,909**(134 MiB / 45.1% CPU) |
+| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,121**(107 MiB / 39.8% CPU) | *Not possible (no QUIC)* | **1,048**(170 MiB / 46.1% CPU) |
+| 64 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **11,532**(170 MiB / 42.4% CPU) | **2,087**(127 MiB / 24.8% CPU) | **9,578**(118 MiB / 45.0% CPU) |
+| 64 KiB | HTTP/2 · TLS | HTTP/2 · plain | **3,848**(77 MiB / 35.2% CPU) | *Not possible* | 🥇 **6,673**(140 MiB / 47.9% CPU) |
+| 64 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **3,495**(78 MiB / 37.3% CPU) | *Not possible* | 🥇 **6,000**(140 MiB / 47.2% CPU) |
+| 64 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **3,396**(125 MiB / 43.6% CPU) | *Not possible* | 🥇 **3,552**(184 MiB / 46.8% CPU) |
+| 64 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **3,934**(138 MiB / 41.5% CPU) | *Not possible (no QUIC)* | **3,307**(199 MiB / 49.0% CPU) |
+| 256 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **3,784**(144 MiB / 30.6% CPU) | **655**(127 MiB / 24.3% CPU) | **2,823**(123 MiB / 38.1% CPU) |
+| 256 KiB | HTTP/2 · TLS | HTTP/2 · plain | **1,382**(86 MiB / 33.1% CPU) | *Not possible* | 🥇 **1,808**(163 MiB / 47.4% CPU) |
+| 256 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **1,277**(87 MiB / 34.7% CPU) | *Not possible* | 🥇 **1,410**(146 MiB / 44.2% CPU) |
+| 256 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **869**(151 MiB / 42.2% CPU) | *Not possible* | 🥇 **979**(191 MiB / 44.0% CPU) |
+| 256 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,090**(148 MiB / 40.6% CPU) | *Not possible (no QUIC)* | **990**(198 MiB / 44.3% CPU) |
nginx/Windows collapses on large reverse bodies in this harness; treat as same-OS only. H1 TLS **64 KiB** ≈ **1.11×** YARP; **256 KiB** ≈ **1.23×**. H2→H1 64 KiB ≈ **1.21×**; H3→H1 64 KiB ≈ **1.18×**.
### Linux — heavier reverse GET (64 KiB / 256 KiB)
-Median of **3** repeats @ `a495a9ae`. Source: Actions [34557778171](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34557778171) + [34557780393](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34557780393) (`compare-bodies`; H3 HAProxy/Envoy peers). Warmup 2s / measure 8s.
+Median of **3** repeats @ `8bfa7852`. Source: Actions [35092832912](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092832912) (`compare-bodies`). Warmup 2s / measure 8s.
| Body | Client | Origin | TWP | nginx | HAProxy | Envoy | YARP |
|---|---|---|---:|---:|---:|---:|---:|
-| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | **15,191**(177 MiB / 41.8% CPU) | **10,978**(103 MiB / 44.9% CPU) | 🥇 **17,384**(85 MiB / 36.7% CPU) | **14,303**(131 MiB / 45.2% CPU) | **11,995**(154 MiB / 48.6% CPU) |
-| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **8,911**(238 MiB / 39.8% CPU) | **2,775**(103 MiB / 16.7% CPU) | **8,042**(84 MiB / 24.1% CPU) | **7,272**(139 MiB / 23.6% CPU) | **7,672**(162 MiB / 45.6% CPU) |
-| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **8,792**(198 MiB / 42.4% CPU) | **0**(peak 2,666 · 133 MiB / 16.1% CPU) | **7,200**(90 MiB / 29.5% CPU) | **7**(137 MiB / 0.1% CPU) | **6,048**(238 MiB / 52.5% CPU) |
-| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | **4,783**(129 MiB / 31.1% CPU) | **3,268**(101 MiB / 42.5% CPU) | 🥇 **5,304**(85 MiB / 26.4% CPU) | **4,485**(146 MiB / 31.5% CPU) | **3,582**(174 MiB / 43.2% CPU) |
-| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | **2,275**(221 MiB / 32.4% CPU) | **861**(102 MiB / 19.2% CPU) | 🥇 **2,802**(83 MiB / 20.4% CPU) | **2,700**(167 MiB / 19.8% CPU) | **2,068**(162 MiB / 41.8% CPU) |
-| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,104**(165 MiB / 41.6% CPU) | **0**(peak 549 · 128 MiB / 15.8% CPU) | **2,046**(90 MiB / 28.9% CPU) | **20**(161 MiB / 0.5% CPU) | **1,765**(224 MiB / 47.7% CPU) |
-| 64 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **13,135**(223 MiB / 42.0% CPU) | **3,378**(80 MiB / 14.6% CPU) | **10,431**(69 MiB / 24.5% CPU) | **10,400**(132 MiB / 23.4% CPU) | **11,945**(145 MiB / 43.6% CPU) |
-| 64 KiB | HTTP/2 · TLS | HTTP/2 · plain | **6,803**(103 MiB / 38.8% CPU) | *Not possible* | **5,393**(86 MiB / 24.5% CPU) | 🥇 **8,349**(141 MiB / 22.1% CPU) | **8,200**(191 MiB / 47.6% CPU) |
-| 64 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **4,299**(103 MiB / 38.9% CPU) | *Not possible* | **2,634**(82 MiB / 24.7% CPU) | **4,879**(147 MiB / 23.1% CPU) | 🥇 **5,624**(180 MiB / 44.4% CPU) |
-| 64 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **4,570**(177 MiB / 54.5% CPU) | *Not possible* | **4,441**(92 MiB / 32.6% CPU) | **22**(143 MiB / 0.2% CPU) | 🥇 **4,826**(237 MiB / 49.5% CPU) |
-| 64 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **6,089**(218 MiB / 42.4% CPU) | **0**(peak 2,056 · 144 MiB / 23.2% CPU) | **5,152**(96 MiB / 34.0% CPU) | **13**(139 MiB / 0.2% CPU) | **4,813**(246 MiB / 49.6% CPU) |
-| 256 KiB | HTTP/2 · plain | HTTP/1 · plain | **3,541**(198 MiB / 34.2% CPU) | **0**(peak 526 · 80 MiB / 8.7% CPU) | **3,864**(70 MiB / 20.3% CPU) | 🥇 **3,876**(159 MiB / 19.9% CPU) | **3,710**(154 MiB / 33.6% CPU) |
-| 256 KiB | HTTP/2 · TLS | HTTP/2 · plain | **1,912**(110 MiB / 28.6% CPU) | *Not possible* | **1,565**(89 MiB / 24.2% CPU) | 🥇 **3,000**(171 MiB / 21.9% CPU) | **2,032**(200 MiB / 39.8% CPU) |
-| 256 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **1,291**(116 MiB / 31.1% CPU) | *Not possible* | **737**(84 MiB / 24.5% CPU) | **1,365**(161 MiB / 22.0% CPU) | 🥇 **1,502**(184 MiB / 40.2% CPU) |
-| 256 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **1,210**(196 MiB / 54.0% CPU) | *Not possible* | **1,260**(94 MiB / 31.2% CPU) | **0**(162 MiB / 0.1% CPU) | 🥇 **1,424**(231 MiB / 47.5% CPU) |
-| 256 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,715**(195 MiB / 43.0% CPU) | **0**(145 MiB / 0.3% CPU) | **1,336**(96 MiB / 30.6% CPU) | **0**(152 MiB / 0.1% CPU) | **1,471**(247 MiB / 47.1% CPU) |
+| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | **8,545**(175 MiB / 44.2% CPU) | **5,990**(100 MiB / 49.8% CPU) | 🥇 **9,584**(83 MiB / 39.5% CPU) | **8,284**(132 MiB / 43.3% CPU) | **7,035**(164 MiB / 47.9% CPU) |
+| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **5,771**(221 MiB / 40.2% CPU) | **1,831**(101 MiB / 16.9% CPU) | **4,786**(84 MiB / 24.4% CPU) | **4,353**(140 MiB / 23.6% CPU) | **4,800**(162 MiB / 48.5% CPU) |
+| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **5,873**(183 MiB / 43.5% CPU) | **0**(peak 1,851 · 129 MiB / 23.6% CPU) | **5,030**(88 MiB / 32.8% CPU) | **123**(139 MiB / 1.1% CPU) | **4,568**(231 MiB / 51.3% CPU) |
+| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | **2,794**(129 MiB / 35.7% CPU) | **1,514**(100 MiB / 47.3% CPU) | 🥇 **3,024**(83 MiB / 32.8% CPU) | **2,727**(145 MiB / 32.2% CPU) | **2,211**(170 MiB / 44.0% CPU) |
+| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | **1,563**(195 MiB / 37.2% CPU) | **595**(100 MiB / 21.3% CPU) | **1,588**(83 MiB / 21.1% CPU) | 🥇 **1,900**(158 MiB / 20.8% CPU) | **1,355**(161 MiB / 45.0% CPU) |
+| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,519**(159 MiB / 42.6% CPU) | **0**(118 MiB / 20.6% CPU) | **1,344**(89 MiB / 30.3% CPU) | **25**(157 MiB / 0.7% CPU) | **1,304**(218 MiB / 47.4% CPU) |
+| 64 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **9,681**(237 MiB / 42.5% CPU) | **2,321**(80 MiB / 12.9% CPU) | **7,167**(70 MiB / 24.3% CPU) | **6,374**(132 MiB / 23.9% CPU) | **8,267**(153 MiB / 45.9% CPU) |
+| 64 KiB | HTTP/2 · TLS | HTTP/2 · plain | **3,709**(103 MiB / 38.7% CPU) | *Not possible* | **2,594**(86 MiB / 24.7% CPU) | 🥇 **5,209**(144 MiB / 22.1% CPU) | **4,981**(184 MiB / 46.5% CPU) |
+| 64 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **2,716**(102 MiB / 38.4% CPU) | *Not possible* | **1,584**(83 MiB / 24.5% CPU) | **3,262**(146 MiB / 23.0% CPU) | 🥇 **3,359**(171 MiB / 45.6% CPU) |
+| 64 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **3,060**(164 MiB / 53.0% CPU) | *Not possible* | **2,380**(90 MiB / 35.2% CPU) | **1**(144 MiB / 0.1% CPU) | 🥇 **3,219**(240 MiB / 49.1% CPU) |
+| 64 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **3,926**(203 MiB / 44.0% CPU) | **0**(peak 1,472 · 143 MiB / 23.0% CPU) | **3,080**(94 MiB / 31.8% CPU) | **0**(140 MiB / 0.1% CPU) | **3,182**(240 MiB / 49.5% CPU) |
+| 256 KiB | HTTP/2 · plain | HTTP/1 · plain | **2,768**(211 MiB / 36.3% CPU) | **877**(79 MiB / 20.1% CPU) | **2,746**(70 MiB / 22.6% CPU) | 🥇 **3,006**(152 MiB / 22.6% CPU) | **2,859**(157 MiB / 38.6% CPU) |
+| 256 KiB | HTTP/2 · TLS | HTTP/2 · plain | **1,203**(113 MiB / 33.7% CPU) | *Not possible* | **772**(90 MiB / 24.3% CPU) | 🥇 **1,769**(169 MiB / 22.0% CPU) | **1,350**(187 MiB / 41.8% CPU) |
+| 256 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **804**(109 MiB / 33.0% CPU) | *Not possible* | **433**(82 MiB / 24.6% CPU) | 🥇 **983**(162 MiB / 22.5% CPU) | **954**(179 MiB / 41.9% CPU) |
+| 256 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **847**(193 MiB / 51.2% CPU) | *Not possible* | **706**(93 MiB / 36.1% CPU) | **0**(165 MiB / 0.1% CPU) | 🥇 **948**(230 MiB / 46.3% CPU) |
+| 256 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,090**(208 MiB / 45.0% CPU) | **0**(peak 216 · 144 MiB / 21.9% CPU) | **872**(96 MiB / 32.0% CPU) | **0**(148 MiB / 0.2% CPU) | **989**(245 MiB / 47.6% CPU) |
On this GHA pass TWP÷YARP H1 TLS ≈ **1.23×** (64 KiB) / **1.28×** (256 KiB); H2→H1 ≈ **1.23×** / **1.16×**; H3→H1 ≈ **1.27×** / **1.13×**. TWP÷nginx H1 TLS ≈ **1.43** / **1.58**. Absolute RPS swings by VM; prefer ratios.
### Windows — POST 64 KiB request + 64 KiB response
-Median of **3** repeats on `windows-latest` @ `9a2b3a1e`. Source: Actions [34441591377](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441591377) (`compare-post`).
+Median of **3** repeats on `windows-latest` @ `8bfa7852`. Source: Actions [35092838467](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092838467) (`compare-post`).
+
+*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
+
+*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
| Client | Origin | TWP | nginx | YARP |
|---|---|---:|---:|---:|
-| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **5,883**(94 MiB / 46.6% CPU) | **352**(142 MiB / 24.7% CPU) | **4,093**(137 MiB / 55.7% CPU) |
-| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,997**(184 MiB / 49.3% CPU) | **352**(143 MiB / 24.7% CPU) | **3,494**(134 MiB / 52.1% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,039**(162 MiB / 40.4% CPU) | *Not possible (no QUIC)* | **1,893**(203 MiB / 48.6% CPU) |
-| HTTP/2 · plain | HTTP/1 · plain | 🥇 **6,237**(182 MiB / 46.1% CPU) | **1,622**(130 MiB / 24.6% CPU) | **5,889**(125 MiB / 53.5% CPU) |
-| HTTP/2 · TLS | HTTP/2 · plain | **8**(83 MiB / 0.2% CPU) | *Not possible* | 🥇 **3,602**(143 MiB / 49.3% CPU) |
-| HTTP/2 · TLS | HTTP/2 · TLS | **8**(88 MiB / 0.1% CPU) | *Not possible* | 🥇 **2,837**(142 MiB / 46.8% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · TLS | **1,769**(178 MiB / 44.3% CPU) | *Not possible* | 🥇 **1,857**(194 MiB / 47.4% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,935**(175 MiB / 42.5% CPU) | *Not possible (no QUIC)* | **1,768**(213 MiB / 47.8% CPU) |
+| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **6,265**(98 MiB / 43.7% CPU) | **361**(142 MiB / 24.7% CPU) | **4,403**(137 MiB / 54.9% CPU) |
+| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **4,552**(190 MiB / 48.0% CPU) | **364**(144 MiB / 24.9% CPU) | **2,959**(134 MiB / 38.4% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,026**(179 MiB / 39.5% CPU) | *Not possible (no QUIC)* | **1,928**(218 MiB / 49.6% CPU) |
+| HTTP/2 · plain | HTTP/1 · plain | 🥇 **6,752**(189 MiB / 45.0% CPU) | **1,799**(130 MiB / 24.7% CPU) | **6,174**(123 MiB / 52.5% CPU) |
+| HTTP/2 · TLS | HTTP/2 · plain | **8**(88 MiB / 0.2% CPU) | *Not possible* | 🥇 **3,734**(145 MiB / 47.7% CPU) |
+| HTTP/2 · TLS | HTTP/2 · TLS | **8**(89 MiB / 0.1% CPU) | *Not possible* | 🥇 **2,795**(145 MiB / 46.3% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · TLS | **1,801**(180 MiB / 45.2% CPU) | *Not possible* | 🥇 **1,870**(209 MiB / 49.3% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,930**(180 MiB / 41.5% CPU) | *Not possible (no QUIC)* | **1,830**(221 MiB / 48.5% CPU) |
TWP leads H1 POST (~**1.5×** YARP), H2 POST (~**1.2×** YARP), and H3 POST (~**1.1×** YARP).
### Linux — POST 64 KiB request + 64 KiB response
-Median of **3** repeats @ `a495a9ae`. Source: Actions [34557782264](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34557782264) (`compare-post`; H3 HAProxy/Envoy peers).
+Median of **3** repeats @ `8bfa7852`. Source: Actions [35092838467](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092838467) (`compare-post`).
| Client | Origin | TWP | nginx | HAProxy | Envoy | YARP |
|---|---|---:|---:|---:|---:|---:|
-| HTTP/1 · TLS | HTTP/1 · plain | **5,003**(137 MiB / 44.0% CPU) | **4,021**(99 MiB / 48.4% CPU) | **5,302**(85 MiB / 40.0% CPU) | 🥇 **5,331**(131 MiB / 40.0% CPU) | **3,440**(175 MiB / 54.5% CPU) |
-| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,311**(219 MiB / 46.8% CPU) | **1,627**(114 MiB / 21.9% CPU) | **2,082**(84 MiB / 24.0% CPU) | **0**(peak 3,225 · 145 MiB / 22.6% CPU) | **2,730**(164 MiB / 48.9% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **3,051**(224 MiB / 43.8% CPU) | **556**(112 MiB / 24.9% CPU) | **2,210**(92 MiB / 34.9% CPU) | **0**(126 MiB / 0.1% CPU) | **2,672**(245 MiB / 49.2% CPU) |
-| HTTP/2 · plain | HTTP/1 · plain | 🥇 **5,664**(230 MiB / 42.2% CPU) | **2,442**(97 MiB / 23.0% CPU) | **2,928**(67 MiB / 23.1% CPU) | **0**(peak 5,034 · 130 MiB / 23.4% CPU) | **4,432**(156 MiB / 46.2% CPU) |
-| HTTP/2 · TLS | HTTP/2 · plain | **8**(112 MiB / 0.2% CPU) | *Not possible* | **1,566**(88 MiB / 23.5% CPU) | 🥇 **3,108**(141 MiB / 23.5% CPU) | **2,548**(186 MiB / 47.7% CPU) |
-| HTTP/2 · TLS | HTTP/2 · TLS | **8**(124 MiB / 0.2% CPU) | *Not possible* | **1,209**(85 MiB / 24.6% CPU) | **1,949**(149 MiB / 20.7% CPU) | 🥇 **2,010**(179 MiB / 46.2% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **1,974**(240 MiB / 49.3% CPU) | *Not possible* | **1,292**(94 MiB / 32.3% CPU) | **0**(126 MiB / 0.1% CPU) | **1,905**(253 MiB / 46.8% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **2,286**(236 MiB / 44.1% CPU) | **462**(120 MiB / 24.8% CPU) | **1,727**(95 MiB / 35.8% CPU) | **0**(126 MiB / 0.1% CPU) | **2,079**(272 MiB / 47.5% CPU) |
+| HTTP/1 · TLS | HTTP/1 · plain | **4,706**(131 MiB / 45.2% CPU) | **3,494**(100 MiB / 48.9% CPU) | **4,766**(85 MiB / 41.1% CPU) | 🥇 **4,816**(133 MiB / 42.2% CPU) | **3,114**(174 MiB / 55.3% CPU) |
+| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,051**(210 MiB / 47.8% CPU) | **1,296**(113 MiB / 21.6% CPU) | **1,773**(82 MiB / 24.1% CPU) | **0**(peak 2,797 · 142 MiB / 22.5% CPU) | **2,483**(170 MiB / 48.6% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,881**(226 MiB / 44.1% CPU) | **457**(109 MiB / 25.0% CPU) | **2,078**(91 MiB / 35.9% CPU) | **0**(125 MiB / 0.1% CPU) | **2,552**(259 MiB / 49.6% CPU) |
+| HTTP/2 · plain | HTTP/1 · plain | 🥇 **5,393**(226 MiB / 44.3% CPU) | **2,294**(97 MiB / 22.5% CPU) | **2,629**(69 MiB / 23.7% CPU) | **0**(peak 4,450 · 133 MiB / 23.6% CPU) | **4,330**(160 MiB / 48.2% CPU) |
+| HTTP/2 · TLS | HTTP/2 · plain | **2**(117 MiB / 0.1% CPU) | *Not possible* | **1,363**(86 MiB / 24.4% CPU) | 🥇 **2,826**(141 MiB / 23.5% CPU) | **2,367**(181 MiB / 47.3% CPU) |
+| HTTP/2 · TLS | HTTP/2 · TLS | **8**(121 MiB / 0.3% CPU) | *Not possible* | **1,048**(87 MiB / 24.5% CPU) | 🥇 **2,023**(147 MiB / 23.1% CPU) | **1,880**(175 MiB / 46.6% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **1,834**(240 MiB / 49.0% CPU) | *Not possible* | **1,118**(95 MiB / 31.6% CPU) | **0**(126 MiB / 0.1% CPU) | **1,761**(255 MiB / 47.1% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **2,088**(238 MiB / 44.9% CPU) | **367**(120 MiB / 24.9% CPU) | **1,492**(95 MiB / 32.4% CPU) | **0**(126 MiB / 0.1% CPU) | **1,944**(257 MiB / 47.9% CPU) |
Linux nginx H1/H2/H3 POST completed (nginx.org mainline). TWP÷YARP H1 ≈ **1.5×**; H2 ≈ **1.3×**; H3 ≈ **1.1×**. TWP÷nginx H3 ≈ **4×**.
### Windows — lossy / high-RTT (H2 HOL / H3 loss)
-Userspace **5 ms** one-way delay + **1%** TCP connection stall (H1/H2) or UDP datagram drop (H3); **64 KiB** GET. Median of **3** repeats on `windows-latest` @ `9a2b3a1e` — [34441595456](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441595456) (`compare-lossy`).
+Userspace **5 ms** one-way delay + **1%** TCP connection stall (H1/H2) or UDP datagram drop (H3); **64 KiB** GET. Median of **3** repeats on `windows-latest` @ `8bfa7852` — [35092841035](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092841035) (`compare-lossy`).
+*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
+*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
| Client | Origin | TWP | nginx | YARP |
|---|---|---:|---:|---:|
-| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **663**(112 MiB / 3.0% CPU) | **652**(143 MiB / 16.4% CPU) | **662**(121 MiB / 4.5% CPU) |
-| HTTP/2 · TLS | HTTP/1 · plain | **0**(peak 86 · 120 MiB / 1.4% CPU) | **0**(peak 17 · 142 MiB / 0.6% CPU) | **0**(peak 16 · 99 MiB / 0.8% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · plain | **0**(67 MiB / 0.1% CPU) | *Not possible (no QUIC)* | **0**(80 MiB / 0.0% CPU) |
-| HTTP/2 · plain | HTTP/1 · plain | **0**(peak 89 · 130 MiB / 1.5% CPU) | **0**(peak 17 · 128 MiB / 0.1% CPU) | **0**(peak 16 · 91 MiB / 0.7% CPU) |
-| HTTP/2 · TLS | HTTP/2 · plain | **0**(peak 8 · 68 MiB / 0.6% CPU) | *Not possible* | **0**(peak 16 · 100 MiB / 0.7% CPU) |
-| HTTP/2 · TLS | HTTP/2 · TLS | **0**(peak 8 · 71 MiB / 0.2% CPU) | *Not possible* | **0**(peak 16 · 99 MiB / 0.8% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · TLS | **0**(70 MiB / 0.1% CPU) | *Not possible* | **0**(80 MiB / 0.0% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · TLS | **0**(69 MiB / 0.0% CPU) | *Not possible (no QUIC)* | **0**(78 MiB / 0.0% CPU) |
+| HTTP/1 · TLS | HTTP/1 · plain | **663**(112 MiB / 4.2% CPU) | **653**(142 MiB / 16.7% CPU) | 🥇 **679**(123 MiB / 3.9% CPU) |
+| HTTP/2 · TLS | HTTP/1 · plain | **0**(peak 89 · 121 MiB / 1.4% CPU) | **0**(peak 17 · 142 MiB / 0.5% CPU) | **0**(peak 16 · 100 MiB / 0.7% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · plain | **0**(67 MiB / 0.0% CPU) | *Not possible (no QUIC)* | **0**(79 MiB / 0.0% CPU) |
+| HTTP/2 · plain | HTTP/1 · plain | **0**(peak 88 · 131 MiB / 0.9% CPU) | **0**(peak 17 · 128 MiB / 0.1% CPU) | **0**(peak 16 · 91 MiB / 0.7% CPU) |
+| HTTP/2 · TLS | HTTP/2 · plain | **0**(peak 8 · 67 MiB / 0.5% CPU) | *Not possible* | **0**(peak 16 · 101 MiB / 0.9% CPU) |
+| HTTP/2 · TLS | HTTP/2 · TLS | **0**(peak 9 · 72 MiB / 0.3% CPU) | *Not possible* | **0**(peak 15 · 100 MiB / 0.6% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · TLS | **0**(71 MiB / 0.0% CPU) | *Not possible* | **0**(80 MiB / 0.0% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · TLS | **0**(69 MiB / 0.0% CPU) | *Not possible (no QUIC)* | **0**(79 MiB / 0.0% CPU) |
TWP H2 HOL leads (~**3.31×** YARP). H3 is the protocol-shape win vs H2 HOL on the same lossy session; Win H3 GHA remains 0 (laptop re-measure kept above).
### Linux — lossy / high-RTT (H2 HOL / H3 loss)
-Median of **3** repeats @ `a495a9ae`. Source: [34557784262](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34557784262) (`compare-lossy`; H3 HAProxy/Envoy peers; lossy H3 uses `quic-http3`).
+Median of **3** repeats @ `8bfa7852`. Source: [35092841035](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092841035) (`compare-lossy`; lossy H3 uses `quic-http3`).
| Client | Origin | TWP | nginx | HAProxy | Envoy | YARP |
|---|---|---:|---:|---:|---:|---:|
-| HTTP/1 · TLS | HTTP/1 · plain | **1,198**(144 MiB / 13.5% CPU) | **1,208**(100 MiB / 12.6% CPU) | 🥇 **1,210**(84 MiB / 7.3% CPU) | **1,200**(128 MiB / 9.4% CPU) | **1,195**(146 MiB / 17.3% CPU) |
-| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **315**(175 MiB / 6.8% CPU) | **40**(99 MiB / 0.3% CPU) | **40**(84 MiB / 0.2% CPU) | **40**(136 MiB / 0.4% CPU) | **40**(129 MiB / 1.4% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · plain | **336**(148 MiB / 14.1% CPU) | **95**(109 MiB / 2.5% CPU) | **62**(85 MiB / 3.9% CPU) | 🥇 **1,225**(141 MiB / 21.5% CPU) | **299**(181 MiB / 20.8% CPU) |
-| HTTP/2 · plain | HTTP/1 · plain | 🥇 **358**(174 MiB / 7.0% CPU) | **40**(78 MiB / 0.2% CPU) | **41**(69 MiB / 0.2% CPU) | **40**(128 MiB / 0.3% CPU) | **40**(122 MiB / 1.0% CPU) |
-| HTTP/2 · TLS | HTTP/2 · plain | **0**(peak 12 · 97 MiB / 0.5% CPU) | *Not possible* | 🥇 **42**(90 MiB / 0.4% CPU) | **40**(136 MiB / 0.3% CPU) | **41**(131 MiB / 1.5% CPU) |
-| HTTP/2 · TLS | HTTP/2 · TLS | **0**(peak 12 · 96 MiB / 0.7% CPU) | *Not possible* | **40**(85 MiB / 0.6% CPU) | **40**(137 MiB / 0.5% CPU) | 🥇 **40**(130 MiB / 1.7% CPU) |
-| HTTP/3 · QUIC | HTTP/2 · TLS | **319**(140 MiB / 25.3% CPU) | *Not possible* | **63**(92 MiB / 4.3% CPU) | 🥇 **1,101**(145 MiB / 24.4% CPU) | **336**(185 MiB / 24.1% CPU) |
-| HTTP/3 · QUIC | HTTP/1 · TLS | **338**(166 MiB / 15.2% CPU) | **91**(114 MiB / 2.9% CPU) | **59**(91 MiB / 4.4% CPU) | 🥇 **1,136**(140 MiB / 23.1% CPU) | **338**(187 MiB / 21.3% CPU) |
+| HTTP/1 · TLS | HTTP/1 · plain | **1,214**(148 MiB / 10.2% CPU) | 🥇 **1,219**(102 MiB / 8.2% CPU) | **1,218**(83 MiB / 4.8% CPU) | **1,209**(128 MiB / 6.6% CPU) | **1,211**(146 MiB / 13.1% CPU) |
+| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **312**(186 MiB / 5.2% CPU) | **40**(101 MiB / 0.2% CPU) | **40**(84 MiB / 0.2% CPU) | **40**(137 MiB / 0.3% CPU) | **40**(129 MiB / 1.1% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · plain | **342**(158 MiB / 10.4% CPU) | **100**(112 MiB / 1.8% CPU) | **60**(86 MiB / 2.7% CPU) | 🥇 **1,578**(140 MiB / 20.3% CPU) | **321**(178 MiB / 15.2% CPU) |
+| HTTP/2 · plain | HTTP/1 · plain | 🥇 **337**(177 MiB / 5.2% CPU) | **40**(78 MiB / 0.1% CPU) | **41**(67 MiB / 0.1% CPU) | **40**(127 MiB / 0.3% CPU) | **41**(123 MiB / 1.0% CPU) |
+| HTTP/2 · TLS | HTTP/2 · plain | **0**(peak 16 · 97 MiB / 0.5% CPU) | *Not possible* | 🥇 **42**(90 MiB / 0.3% CPU) | **40**(136 MiB / 0.2% CPU) | **41**(133 MiB / 1.0% CPU) |
+| HTTP/2 · TLS | HTTP/2 · TLS | **0**(peak 13 · 98 MiB / 0.5% CPU) | *Not possible* | 🥇 **41**(84 MiB / 0.4% CPU) | **40**(138 MiB / 0.4% CPU) | **41**(131 MiB / 1.2% CPU) |
+| HTTP/3 · QUIC | HTTP/2 · TLS | **325**(143 MiB / 19.3% CPU) | *Not possible* | **66**(91 MiB / 3.2% CPU) | 🥇 **1,459**(145 MiB / 23.4% CPU) | **349**(190 MiB / 20.2% CPU) |
+| HTTP/3 · QUIC | HTTP/1 · TLS | **325**(165 MiB / 10.9% CPU) | **99**(118 MiB / 1.9% CPU) | **63**(90 MiB / 3.1% CPU) | 🥇 **1,456**(141 MiB / 22.1% CPU) | **350**(185 MiB / 18.6% CPU) |
TWP H2 HOL ≫ YARP (~**7.7×**). H3 TWP÷YARP ≈ **1×**.
@@ -571,7 +579,7 @@ TWP H2 HOL ≫ YARP (~**7.7×**). H3 TWP÷YARP ≈ **1×**.
These runs isolate slow app readers, origin-early response, HTTP/2 duplex, and WebSocket echo. See [TWP vs YARP IO model](Performance-Profiling#twp-vs-yarp-io-model). Laptop 1-rep numbers are on [Performance Local Lab](Performance-Local-Lab#architecture-sensitive).
-Median of **3** repeats on matched 4 vCPU / 16 GiB runners @ `9a2b3a1e` ([34441578556](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441578556)). Slow consumer = 256 KiB GET, 16 KiB read + 8 ms sleep. Early response = 64 KiB POST, origin writes after 8 KiB. Duplex HTTP/2 = overlapping 64 KiB POST on H2 TLS↔H2 TLS. WebSocket row = H1 Upgrade echo round-trips/sec (not RFC 8441; see WebSocket RFC 8441 table below).
+Median of **3** repeats on matched 4 vCPU / 16 GiB runners @ `8bfa7852` ([35092846209](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092846209)). Slow consumer = 256 KiB GET, 16 KiB read + 8 ms sleep. Early response = 64 KiB POST, origin writes after 8 KiB. Duplex HTTP/2 = overlapping 64 KiB POST on H2 TLS↔H2 TLS. WebSocket row = H1 Upgrade echo round-trips/sec (not RFC 8441; see WebSocket RFC 8441 table below).
Lossy-link runs (slow **network**) are already published above; they are not a slow **app** reader.
@@ -581,33 +589,33 @@ Lossy-link runs (slow **network**) are already published above; they are not a s
| Scenario | Client | Origin | TWP | nginx | YARP |
|---|---|---|---:|---:|---:|
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **243**(101 MiB / 5.9% CPU) | **204**(143 MiB / 24.6% CPU) | **241**(109 MiB / 4.8% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | **256**(131 MiB / 3.7% CPU) | **230**(142 MiB / 24.4% CPU) | 🥇 **256**(111 MiB / 5.4% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **264**(101 MiB / 17.4% CPU) | *Not possible (no QUIC)* | **255**(158 MiB / 20.4% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **5,611**(99 MiB / 45.5% CPU) | **363**(143 MiB / 24.7% CPU) | **3,919**(138 MiB / 53.9% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,934**(169 MiB / 48.8% CPU) | **0**(peak 318 · 144 MiB / 24.8% CPU) | **3,200**(135 MiB / 53.4% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **3,012**(152 MiB / 42.0% CPU) | *Not possible (no QUIC)* | **2,602**(203 MiB / 51.0% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · plain | HTTP/1 · plain | **248**(112 MiB / 3.6% CPU) | **248**(127 MiB / 9.4% CPU) | 🥇 **256**(104 MiB / 6.0% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(peak 8 · 70 MiB / 1.0% CPU) | *Not possible* | 🥇 **256**(136 MiB / 9.3% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(92 MiB / 0.1% CPU) | *Not possible* | **0**(110 MiB / 0.1% CPU) |
-| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(91 MiB / 0.1% CPU) | *Not possible* | **0**(111 MiB / 0.1% CPU) |
-| Duplex (WebSocket / H1 Upgrade) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **24,498**(97 MiB / 43.0% CPU) | **12,337**(143 MiB / 24.6% CPU) | **23,100**(89 MiB / 44.6% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **248**(100 MiB / 4.9% CPU) | **192**(143 MiB / 24.7% CPU) | **240**(111 MiB / 4.6% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | **248**(121 MiB / 4.6% CPU) | **180**(142 MiB / 24.7% CPU) | 🥇 **253**(112 MiB / 6.7% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | **273**(102 MiB / 15.2% CPU) | *Not possible (no QUIC)* | 🥇 **274**(162 MiB / 15.7% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **11,064**(101 MiB / 41.4% CPU) | **679**(143 MiB / 24.9% CPU) | **7,558**(138 MiB / 53.7% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | **2,870**(181 MiB / 40.4% CPU) | **0**(peak 343 · 144 MiB / 24.9% CPU) | 🥇 **3,336**(135 MiB / 51.9% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,141**(149 MiB / 41.8% CPU) | *Not possible (no QUIC)* | **1,888**(199 MiB / 52.7% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · plain | HTTP/1 · plain | **248**(106 MiB / 3.9% CPU) | **248**(127 MiB / 8.7% CPU) | 🥇 **256**(104 MiB / 4.3% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(peak 8 · 72 MiB / 0.3% CPU) | *Not possible* | 🥇 **256**(141 MiB / 4.9% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(97 MiB / 0.1% CPU) | *Not possible* | **0**(123 MiB / 0.2% CPU) |
+| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(95 MiB / 0.1% CPU) | *Not possible* | **0**(114 MiB / 0.2% CPU) |
+| Duplex (WebSocket / H1 Upgrade) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **66,034**(97 MiB / 41.3% CPU) | **36,248**(143 MiB / 24.6% CPU) | **61,591**(89 MiB / 44.1% CPU) |
#### Linux
| Scenario | Client | Origin | TWP | nginx | HAProxy | Envoy | YARP |
|---|---|---|---:|---:|---:|---:|---:|
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | **467**(119 MiB / 9.7% CPU) | **412**(100 MiB / 9.7% CPU) | 🥇 **472**(82 MiB / 5.9% CPU) | **466**(139 MiB / 6.3% CPU) | **419**(148 MiB / 14.5% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | **472**(145 MiB / 20.3% CPU) | **459**(100 MiB / 22.4% CPU) | 🥇 **474**(84 MiB / 9.3% CPU) | **466**(157 MiB / 7.6% CPU) | **473**(152 MiB / 25.1% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **472**(131 MiB / 35.5% CPU) | **0**(peak 379 · 124 MiB / 22.5% CPU) | **468**(88 MiB / 11.7% CPU) | **0**(145 MiB / 0.1% CPU) | **470**(199 MiB / 42.5% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | **4,751**(132 MiB / 47.4% CPU) | **3,736**(100 MiB / 50.2% CPU) | 🥇 **4,951**(85 MiB / 43.0% CPU) | **0**(peak 2,679 · 130 MiB / 25.6% CPU) | **3,212**(170 MiB / 56.5% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,300**(232 MiB / 48.8% CPU) | **0**(peak 1,308 · 115 MiB / 23.5% CPU) | **2,508**(84 MiB / 23.9% CPU) | **0**(peak 2,660 · 150 MiB / 24.1% CPU) | **2,234**(171 MiB / 49.0% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,868**(195 MiB / 45.0% CPU) | **0**(peak 456 · 115 MiB / 24.8% CPU) | **1,977**(91 MiB / 34.7% CPU) | **0**(127 MiB / 0.1% CPU) | **2,117**(244 MiB / 48.3% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · plain | HTTP/1 · plain | **476**(140 MiB / 15.2% CPU) | **454**(79 MiB / 11.8% CPU) | 🥇 **478**(68 MiB / 6.4% CPU) | **473**(151 MiB / 4.6% CPU) | **478**(148 MiB / 15.8% CPU) |
-| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/2 · TLS | **8**(97 MiB / 2.5% CPU) | *Not possible* | **448**(88 MiB / 21.4% CPU) | **455**(148 MiB / 12.7% CPU) | 🥇 **462**(159 MiB / 31.1% CPU) |
-| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(108 MiB / 0.1% CPU) | *Not possible* | 🥇 **0**(93 MiB / 0.1% CPU) | **0**(peak 1,942 · 154 MiB / 20.0% CPU) | **0**(148 MiB / 0.2% CPU) |
-| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(108 MiB / 0.1% CPU) | *Not possible* | **0**(93 MiB / 0.1% CPU) | **0**(peak 1,994 · 156 MiB / 20.8% CPU) | **0**(145 MiB / 0.2% CPU) |
-| Duplex (WebSocket / H1 Upgrade) | HTTP/1 · TLS | HTTP/1 · plain | **31,261**(126 MiB / 43.2% CPU) | 🥇 **34,370**(100 MiB / 35.8% CPU) | **33,060**(82 MiB / 38.5% CPU) | **32,066**(127 MiB / 39.8% CPU) | **27,020**(125 MiB / 43.8% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | **468**(125 MiB / 9.2% CPU) | **416**(101 MiB / 9.4% CPU) | 🥇 **472**(84 MiB / 5.8% CPU) | **469**(139 MiB / 6.2% CPU) | **419**(145 MiB / 14.0% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | **473**(157 MiB / 12.2% CPU) | **427**(102 MiB / 9.4% CPU) | **474**(84 MiB / 5.4% CPU) | 🥇 **480**(157 MiB / 5.0% CPU) | **474**(148 MiB / 16.2% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **480**(135 MiB / 31.0% CPU) | **0**(peak 347 · 124 MiB / 18.5% CPU) | **479**(90 MiB / 7.8% CPU) | **1**(156 MiB / 0.2% CPU) | **476**(196 MiB / 37.0% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | **6,746**(148 MiB / 44.2% CPU) | **5,410**(101 MiB / 48.0% CPU) | 🥇 **7,255**(84 MiB / 39.9% CPU) | **0**(peak 673 · 130 MiB / 6.6% CPU) | **4,645**(176 MiB / 55.1% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | **2,465**(210 MiB / 48.0% CPU) | **0**(peak 858 · 112 MiB / 15.2% CPU) | 🥇 **2,509**(85 MiB / 24.7% CPU) | **0**(peak 2,574 · 145 MiB / 23.1% CPU) | **2,226**(171 MiB / 48.6% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **4,647**(224 MiB / 46.4% CPU) | **0**(peak 706 · 122 MiB / 24.5% CPU) | **2,866**(93 MiB / 32.9% CPU) | **0**(126 MiB / 0.1% CPU) | **3,107**(258 MiB / 47.4% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · plain | HTTP/1 · plain | **475**(139 MiB / 14.4% CPU) | **463**(79 MiB / 11.5% CPU) | 🥇 **476**(68 MiB / 6.2% CPU) | **472**(150 MiB / 4.5% CPU) | **474**(149 MiB / 14.7% CPU) |
+| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/2 · TLS | **9**(96 MiB / 1.8% CPU) | *Not possible* | **447**(90 MiB / 12.9% CPU) | **470**(157 MiB / 9.3% CPU) | 🥇 **470**(172 MiB / 21.4% CPU) |
+| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(117 MiB / 0.1% CPU) | *Not possible* | 🥇 **8**(95 MiB / 0.2% CPU) | **0**(peak 2,844 · 152 MiB / 19.7% CPU) | **0**(141 MiB / 0.1% CPU) |
+| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | **0**(118 MiB / 0.1% CPU) | *Not possible* | **0**(93 MiB / 0.1% CPU) | **0**(peak 2,526 · 156 MiB / 17.2% CPU) | **0**(146 MiB / 0.2% CPU) |
+| Duplex (WebSocket / H1 Upgrade) | HTTP/1 · TLS | HTTP/1 · plain | **45,832**(128 MiB / 44.4% CPU) | 🥇 **49,425**(102 MiB / 36.9% CPU) | **48,657**(82 MiB / 39.8% CPU) | **46,062**(127 MiB / 41.2% CPU) | **41,388**(126 MiB / 45.3% CPU) |
Slow consumer is sleep-bound; H1/H2/H3 sit in the same band. Early-response H1/H2/H3: TWP leads (H1 early ≈ **2.00×** / **1.47×** YARP Win/Linux). **Duplex H2**: YARP leads by design — Win ≈ **0.59×** (1,270 / 2,135), Linux ≈ **0.15×** (282 / 1,882); irreducible concurrent-copier cell (see [IO model](Performance-Profiling#twp-vs-yarp-io-model)). WebSocket: TWP÷YARP Windows ≈ **1.06×**; Linux nginx leads.
@@ -617,49 +625,49 @@ Isolates keep-alive tiny GET vs **new connection per request** (handshake-domina
#### Windows
-Median of **3** repeats on `windows-latest` @ `9a2b3a1e`. Source: Actions [34441599658](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441599658). Absolute RPS on GHA swings hard; prefer **TWP÷YARP**.
+Median of **3** repeats on `windows-latest` @ `8bfa7852`. Source: Actions [35092843724](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092843724). Absolute RPS on GHA swings hard; prefer **TWP÷YARP**.
*Not possible:* **HAProxy** and **Envoy** columns are omitted (no official Windows port).
| Workload | TWP | nginx | YARP |
|---|---:|---:|---:|
-| Keep-alive · tiny GET | 🥇 **20,612**(87 MiB / 47.9% CPU) | **8,972**(142 MiB / 24.8% CPU) | **18,392**(105 MiB / 50.9% CPU) |
-| New-connection · tiny GET | 🥇 **732**(88 MiB / 9.5% CPU) | **0**(peak 248 · 140 MiB / 24.4% CPU) | **725**(113 MiB / 10.7% CPU) |
-| Keep-alive · 256 KiB GET | 🥇 **2,741**(130 MiB / 47.0% CPU) | **0**(peak 162 · 142 MiB / 24.6% CPU) | **2,699**(136 MiB / 49.4% CPU) |
+| Keep-alive · tiny GET | 🥇 **21,760**(85 MiB / 49.0% CPU) | **9,611**(142 MiB / 24.8% CPU) | **19,339**(101 MiB / 48.2% CPU) |
+| New-connection · tiny GET | 🥇 **747**(89 MiB / 9.4% CPU) | **0**(peak 247 · 140 MiB / 24.2% CPU) | **732**(113 MiB / 9.5% CPU) |
+| Keep-alive · 256 KiB GET | 🥇 **2,948**(133 MiB / 45.9% CPU) | **0**(peak 158 · 143 MiB / 24.5% CPU) | **2,718**(128 MiB / 45.9% CPU) |
#### Linux
-Median of **3** repeats @ `9a2b3a1e`. Source: Actions [34441599658](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441599658).
+Median of **3** repeats @ `8bfa7852`. Source: Actions [35092843724](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092843724).
| Workload | TWP | nginx | HAProxy | Envoy | YARP |
|---|---:|---:|---:|---:|---:|
-| Keep-alive · tiny GET | **36,114**(108 MiB / 49.4% CPU) | 🥇 **44,372**(102 MiB / 40.8% CPU) | **43,256**(84 MiB / 43.3% CPU) | **27,636**(128 MiB / 57.3% CPU) | **32,712**(135 MiB / 49.8% CPU) |
-| New-connection · tiny GET | **1,549**(128 MiB / 40.0% CPU) | 🥇 **1,598**(103 MiB / 36.2% CPU) | **1,454**(85 MiB / 37.7% CPU) | **1,379**(129 MiB / 44.5% CPU) | **1,525**(149 MiB / 38.9% CPU) |
-| Keep-alive · 256 KiB GET | **3,790**(126 MiB / 31.8% CPU) | **2,421**(101 MiB / 48.7% CPU) | 🥇 **3,940**(84 MiB / 28.4% CPU) | **3,544**(145 MiB / 32.3% CPU) | **3,031**(160 MiB / 42.1% CPU) |
+| Keep-alive · tiny GET | **23,990**(112 MiB / 50.2% CPU) | **28,472**(100 MiB / 40.9% CPU) | 🥇 **28,484**(83 MiB / 43.2% CPU) | **17,372**(127 MiB / 58.6% CPU) | **20,581**(136 MiB / 50.5% CPU) |
+| New-connection · tiny GET | **976**(116 MiB / 47.4% CPU) | **1,017**(100 MiB / 44.5% CPU) | **956**(82 MiB / 44.1% CPU) | 🥇 **1,072**(129 MiB / 41.4% CPU) | **967**(152 MiB / 46.4% CPU) |
+| Keep-alive · 256 KiB GET | **2,708**(128 MiB / 36.8% CPU) | **1,740**(100 MiB / 53.7% CPU) | 🥇 **2,966**(84 MiB / 33.2% CPU) | **2,734**(146 MiB / 34.2% CPU) | **2,145**(172 MiB / 45.3% CPU) |
All three workloads are **>1.00×** YARP on both OS. nginx leads Linux keep-alive tiny and Linux new-connection; TWP is second, YARP third.
## Unary gRPC (H2 TLS)
-Unary Echo **RPC/s** @ c=64 over H2 TLS→H2 TLS for Titanium, YARP, nginx (`grpc_pass`), HAProxy, and Envoy (OS-possible peers only). Not folded into the architecture-sensitive tables. Median of **3** repeats @ `9a2b3a1e` — [34441548073](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441548073).
+Unary Echo **RPC/s** @ c=64 over H2 TLS→H2 TLS for Titanium, YARP, nginx (`grpc_pass`), HAProxy, and Envoy (OS-possible peers only). Not folded into the architecture-sensitive tables. Median of **3** repeats @ `8bfa7852` — [35092856793](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092856793).
| OS | Titanium | YARP | nginx | HAProxy | Envoy |
|---|---:|---:|---:|---:|---:|
-| Windows | **53,762**(88 MiB / 23.6% CPU) | **30,754**(123 MiB / 46.9% CPU) | **0**(153 MiB / 24.8% CPU) | *Not possible* | *Not possible* |
-| Linux | **42,675**(120 MiB / 30.3% CPU) | **24,232**(159 MiB / 41.6% CPU) | **0**(120 MiB / 24.9% CPU) | **8,683**(84 MiB / 24.6% CPU) | **16,110**(128 MiB / 20.6% CPU) |
-| macOS | **14,924**(94 MiB / 20.1% CPU) | **8,581**(128 MiB / 28.4% CPU) | **0**(97 MiB / 2.5% CPU) | **0**(68 MiB / 5.1% CPU) | **0**(84 MiB / 19.0% CPU) |
+| Windows | **111,791**(116 MiB / 22.8% CPU) | **63,757**(120 MiB / 41.9% CPU) | **0**(143 MiB / 24.9% CPU) | *Not possible* | *Not possible* |
+| Linux | **78,834**(121 MiB / 32.5% CPU) | **45,952**(149 MiB / 42.2% CPU) | **0**(131 MiB / 25.1% CPU) | **18,339**(83 MiB / 24.2% CPU) | **30,733**(127 MiB / 21.4% CPU) |
+| macOS | **18,711**(97 MiB / 24.0% CPU) | **13,574**(137 MiB / 34.5% CPU) | **0**(61 MiB / 3.1% CPU) | **0**(63 MiB / 18.4% CPU) | **7,199**(80 MiB / 20.7% CPU) |
## Unary gRPC (H2 TLS → h2c)
-Unary Echo **RPC/s** @ c=64 over **H2 TLS → h2c** (edge TLS, cleartext H2 origin). Peers: Titanium, YARP, HAProxy, Envoy. Mode: `compare-grpc` (`*-grpc-h2c`). Win/Linux from prior paste; macOS @ `a495a9ae` — [34557768163](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34557768163).
+Unary Echo **RPC/s** @ c=64 over **H2 TLS → h2c** (edge TLS, cleartext H2 origin). Peers: Titanium, YARP, HAProxy, Envoy. Mode: `compare-grpc` (`*-grpc-h2c`). Median of **3** repeats @ `8bfa7852` — [35092859116](https://github.com/justcoding121/titanium-web-proxy/actions/runs/35092859116).
*Not possible:* **nginx** column omitted (no H2 upstream).
| OS | Titanium | YARP | HAProxy | Envoy |
|---|---:|---:|---:|---:|
-| Windows | **60,942**(93 MiB / 22.8% CPU) | **33,648**(111 MiB / 49.7% CPU) | *Not possible* | *Not possible* |
-| Linux | **44,967**(121 MiB / 29.2% CPU) | **24,678**(150 MiB / 44.1% CPU) | **7,735**(82 MiB / 24.6% CPU) | **12,516**(128 MiB / 22.6% CPU) |
-| macOS | **14,333**(90 MiB / 23.8% CPU) | **9,316**(132 MiB / 34.8% CPU) | **0**(65 MiB / 17.6% CPU) | **0**(80 MiB / 20.6% CPU) |
+| Windows | **60,626**(93 MiB / 24.4% CPU) | **34,219**(117 MiB / 47.2% CPU) | *Not possible* | *Not possible* |
+| Linux | **43,638**(126 MiB / 28.5% CPU) | **24,134**(169 MiB / 43.3% CPU) | **7,783**(82 MiB / 24.4% CPU) | **12,047**(128 MiB / 22.2% CPU) |
+| macOS | **24,524**(95 MiB / 24.8% CPU) | **16,722**(130 MiB / 35.8% CPU) | **0**(65 MiB / 17.4% CPU) | **0**(79 MiB / 20.6% CPU) |
## WebSocket (H1 TLS → H1 TLS)
@@ -667,9 +675,9 @@ WebSocket echo round-trips/sec over **dual-TLS** H1 (`proxy_ssl` style). Mode: `
| OS | Titanium | YARP | nginx | HAProxy | Envoy |
|---|---:|---:|---:|---:|---:|
-| Windows | **27,472**(101 MiB / 44.3% CPU) | **26,068**(92 MiB / 44.1% CPU) | **13,043**(142 MiB / 24.7% CPU) | *Not possible* | *Not possible* |
-| Linux | **25,489**(138 MiB / 43.2% CPU) | **22,409**(134 MiB / 44.1% CPU) | **27,071**(103 MiB / 36.8% CPU) | **26,782**(85 MiB / 38.9% CPU) | **27,011**(127 MiB / 38.1% CPU) |
-| macOS | **8,868**(118 MiB / 35.0% CPU) | **12,323**(127 MiB / 28.2% CPU) | **6,231**(74 MiB / 21.1% CPU) | **11,067**(68 MiB / 30.2% CPU) | **4,940**(80 MiB / 30.3% CPU) |
+| Windows | **66,023**(102 MiB / 42.5% CPU) | **63,442**(90 MiB / 46.5% CPU) | **31,084**(143 MiB / 24.7% CPU) | *Not possible* | *Not possible* |
+| Linux | **38,814**(137 MiB / 44.3% CPU) | **34,209**(136 MiB / 45.8% CPU) | **41,105**(108 MiB / 38.2% CPU) | **42,059**(85 MiB / 39.8% CPU) | **40,348**(127 MiB / 40.1% CPU) |
+| macOS | **10,520**(144 MiB / 33.3% CPU) | **8,988**(145 MiB / 33.6% CPU) | **10,184**(81 MiB / 26.4% CPU) | **10,985**(67 MiB / 32.0% CPU) | **15,732**(81 MiB / 30.8% CPU) |
## WebSocket (H2 TLS 8441 → H1)
@@ -679,9 +687,9 @@ WebSocket echo over **RFC 8441** extended CONNECT (H2 TLS client → H1 plain or
| OS | Titanium | YARP | HAProxy | Envoy |
|---|---:|---:|---:|---:|
-| Windows | **25,713**(144 MiB / 42.9% CPU) | **0**(167 MiB / 20.9% CPU) | *Not possible* | *Not possible* |
-| Linux | **1,531**(147 MiB / 16.0% CPU) | **0**(161 MiB / 25.4% CPU) | **1,534**(82 MiB / 5.3% CPU) | **0**(124 MiB / 0.3% CPU) |
-| macOS | **11,061**(384 MiB / 43.3% CPU) | **0**(132 MiB / 17.7% CPU) | **10,080**(66 MiB / 33.1% CPU) | **0**(73 MiB / 0.3% CPU) |
+| Windows | **23,244**(172 MiB / 41.7% CPU) | **0**(165 MiB / 19.8% CPU) | *Not possible* | *Not possible* |
+| Linux | **1,530**(143 MiB / 16.9% CPU) | **0**(172 MiB / 25.2% CPU) | **1,535**(82 MiB / 5.4% CPU) | **0**(123 MiB / 0.3% CPU) |
+| macOS | **4,262**(219 MiB / 29.2% CPU) | **0**(86 MiB / 12.1% CPU) | **4,087**(66 MiB / 35.2% CPU) | **0**(73 MiB / 0.6% CPU) |
## Other measurements
diff --git a/wiki/images/rps-practical-heavier-linux.png b/wiki/images/rps-practical-heavier-linux.png
index e2c02dc6c6538152c1f982fc89ef6d3cdd903c95..282cb578faac036f162ae383ce52ca27d3ecd20b 100644
GIT binary patch
literal 100847
zcmc$`c|4T=+deEBiqJx``y^#4N|x+|EG6q0>rk33*=p>JHEBg<$i54MiLx6@DmAk2
z2BXB-cY_(s^PWEU_rCAn^T+dg{(7Eyy)<*_nrp6kFXwq2$9bHdncUW6XXRsMU|?W3
z(AP0zU|^YKU|_m@gcs=8)#3Q7u
zm*-XKD_5kiToiHf@$p6~%gA{A_ZiY&kDO&5jWX?ks~q*#w?;BBa6hI09Qrb(nZPoHr15bGi%HsXy~BE3
z+$VIk1RjQPh!$Ybn
z&fsI$#D%YVIB?
z=PwM00{++G-Eaog|8;mplR@Zz4#UrgFmV3Qq2{sU;Vl1iD8l@I-o^jzVnaOlo2zwiIVeCUg~{vmWfDD|pyY&~kTN6)tP3AUHon%M0IxL-Dg!s}Q~$g*zOHUraEljXmj7Jd
zwf763qNlrrgoN&dx`_1>p}K1#XrVMd4lQjzDrn2jC-)(WQ+;?`W|mwG#mvM)?Q`-c
z`;0aA|1g@>`z=+Z$Um@2SN3(M?Qd6u6Ko!G)d*u?SWo*ppC5gcQHPEcd1SS4z~rI&
z1Ihwhe)`w1%j5M+ZNg!nDjy8l)IOTFI}Sz3rKk(z-)Dxp@hf`zR9E7_DLk$Y0pOY*
zq|WqZzgK+ni+OKv%c}^1JBJI3|tTGz&8!_7$3nQunusmn;*d?dKWy0)1za
zoo&D%IyDO8P_=hjpA?SvR3L5+8HU=R&)jr2O%GQG3mH-$}@sNMl
z(8J9IY?IB(1P*EM*|bmo7`jgxxl}#&n~|I0tauDG$uM#R#a7fJw8=Zb7U+|
zPvGI>OLjGvCyXQ$xx^dx$pnvYFL@8bE8km|+V~AxJC8g)A-b3q#qm5SmF@J!4`ufC
zb&l25=`K;vAxmtmp&Y!jU(VlpH%Ys-Df!P~FDXUksxlnehX1mo=Idkb=I3jfN5rk_
z{8!-Hya(8@eM~nCqqu(X_P9Uc&yTm-jmD)ml@-5QW9pH&?wH%kHD7O8_)K1Jm#H60
zhmGpZ_eGZrd*QY$slQ*yV=$2$*6|x+EabZL0anI8$9IGN+~G2IAp-&as)_T;Vd}Q0CbIOj3QiZK8`o
zzR1>xJFOQ*MMeKi;T^*$bMNh&o+~h5v+9`>16sCo5|Wc$_o>^}JPsi}<3pOsaT*81
z5dIBq?nBHR0xrU+&DTch%JRekhO?&Onuy`RT+8sDuGUVI5BBzH3OsiymL
zwWN&pN_;qTl1o?^`2JTiIF7qQ@=zH^FQBf_$@laa(Q8cxdiQrP}h5}VF!
z44k3Iv}m$=D
zNVSqNlXA>d^O~KvdfM7l8^NE5A~CBKZu;gOI#-@|NSyD(Z$Z7FP)H}|waM_pIOCoL
z=;^xHK(FHw%%6N`yIaZ{*75HwD^2tH5RxQ?sa(chzKhx0x41o*>sQnYpgyOOdfGYeKnAyp4k29GiT-i;@=*1`$H?oMn}4eV%sAS+1AzeH~Sg`
zH)eDz$D0l)?XcQMi0Y1!kIqzj{Pi3H|C&%Eg;qd#3rij>wWWfeB&PKHV<`>p`e$4BDEXwz|Cla!U-vGp188
zf*`0C^vMf^+mS@;#*o?-5O3FI`oO0N5Oz2FjQ8W3RzI^r-1>^%XUtcbgSG6Cb8X}2
zRr^$Yg2br`hp%oL_=jzRBq5F2tf=*IIO;Tg5iMU8lai8Zo05W+ZKnGUT91tGfgL_g
z-36=Mq
z27d{PO15kY-M1rDSyr_HR}34eTG&q+m(PT*tJ5B
z6nQ{2GAjlXmWte+&~?!J*%jy86C~a)EJ*67=AKygom1OcswR$k;L2{i;^W32oaoSP
zaOpX#h>IXUle{ecQO0g}3@rt#J7(1rdaPvd_^pi;Oj^Bo>fXz%o!-^NP^w3@19}P7
zYj8mqon6R!Jm=^2$}5SRbg>pw0?i}uwo
z1um3UFV5!1anxTDB=+39o+wv*aV
zSR!K&PZ<$acK^J!|K@rvNL|fq8jt>nsQ&i*-4C`7?IkFzyS!x1aMLDg&MSqcYJ)Ev
zQXRH=Z~p$aC6|K7=pS;vY@lxPJXrB@JL)>xq%{~k@T)z>*d>J}{Vl7q1KBA~?Y?#l
z$Cfj0!Or_DH>~E?qPMx&>{Y{+-P}0!`A*(>CP@SA+-0A12j$GbP9>6nA}!!@hZfdV
z*5~#>Fs3WgWjiWXYe^B+*k5GchWLo7yI#2MJy>FW&~*+=(f5YDFlkGol&d#E8Q}w(
z76Y2pE0)}h7VKJm5&CgLYGVuH^ZV~$_-}D}25--oT~fAe+*`l7t(eYq7S#z>PS$bm
z%i(m8l@)(=QZ0&wlPseWy0`P8egA;E(s+o8d8gQ-=#6M=8Cc|vAZ1ajJP3(SxRpj~
zXVX}Bwl>m(3f9S9JB>=D#k$(6!VB*1Q?|zUA#A62l>HXpd8iE8`=^KPmfBWR=qWPk
z;hDXV2_!t=SG1=}`9xUYiM=}d9T83S1kqEwqm>UxRLTO@sVc1D;?hm&+ApvovwVTB
zq{PIT`h~Lk5P*~LYj&g6_=&zp_zL?P?>obOkoyrDJ~(Y=4!+*b*@2
z?_<5DzP(!ZicUY&-=R9i9yDRu%%
zkM6}f8E~Jzj^bc(V2s1E2#0k0gB?KaLboJ=^|(`DlsY3NUO7aVp{_JJ6oR$DyV?`<
z``Lv(kJ{-(Th@Jgv^Z{4^!eB_9l<{p>|k3unv`Dl(*zNHJ6ZNCq6pF#GPF$$Yijp~
z-M1D&lm{mF;(vE|#7+WWsDAgprPk=l=M^z=D$CcBdbss@eRJHuow|LkMJQ6;7b--({c=yPfX_1qFR?gPLCFUD%!o
z_G--3UO}rf=_W7Wn`Mgk#e+QXCSjy>|1nZ!@uM$hj%Z=%1ei6=kbjBLo94xlkNa9q
z5ae|<7j({ipP@{)Y9fwu$>!4sY5Tew;de|o<)vz}9Zgm__uwM-Eij1B=bj*$hSkXW
zQrS7y;cs=9A~vIF0MyS$-e*k|*UZ&K#-SfB
zbH2ASR7b)bRir4?+tq#alk0L79)`g_h<}c^4z{RJ
z8VgUe5}&Jr3DO_{7FxnCMyz|3>Wy5bxmJZ(%ozC?CX+0$2D+1Z3`+CldW;aG>4&eUfCV(8J%8sookS`JTtj2zB&QX$G#dqF<7`%dmxp>6hSY4W2^-vr_0u^kN2it}~u$`E{%
zTvBq;;q7^&F`}VP^**t&eJ!XT)Z=
zLF{qwJ3JOJ_3Dbsgl{0AG4_EQfId=^%&qqwLU+Z8OGBT|An*wf3B9}LhS#UtQFMA=
zMXNNXZm+CNP(hZiZL;@nfs72+15i9p+#1Pr$V8EoX(%%T>I<31;VoD6nVzLY+D!86i7Sp`W`DHtl{r<#zbvel9&djm=iA2l|msc=35KGWvizH3eR?(g(X83P_jE!EnK@N
z&cwf%$qB{!uAiC=p36_NyL{u&UbBZAU{4B%n-n1Yt(Cbzl33n(J|Hk5ro@0%s?>%s
z=|)rl+(9LFedcK9`Sb4Cw_h>B%f!UUClko%dhyjJ&+)o=g4yZ@J$JMhIj~5*WC)qe
z2dM4}!LslLISkMeWQgDN0FZK)h)FFZZwQ%*h3l|`vj6g8rjzQ9>JIpyY3j#|-GB|x
zGAGS`RJGveG8q#1H8(f+&k(0-;%4m{SVA$VvgXDTk-$)<)t)n|0Uv0nW88&tIwU5q
z@C0T(uCJlTjy1zdZ3c$G&Lh&fgEUZt+7)Iy7WjWPp3=#Qko8>{^d-1}s1duuMxQmC
zqBVR2T{AnbhCVsaut>@X_5}H6V(=rKQr#R168pk*^Vvcb{u^d*D-y!?$o-~RkNkF(
zVdew)@5whmQkpiwefd{IZwyFDz|VLb_31NG)D5xACqNDD#0H@mezV=#rd|ASE05SG
zhGeio9*2}XoccD1bw99TC5l!tY$0UA2caYwk5U2$x3CfL#%
zffg8CVE4>4WNX@6Z!f!)a-j}M>O4^LhIYa?cAn%0=0O`BkjJLuSy+aq@y*yu7?+
z0wJp;79KMrYIlr0?z0^*`x^!E*uSGEj_`k}vCGUkdKv7QQB^FM+2)$
zd&$aiF6z5=z6*78$0eb~Tw4^dP@by&m1{L4O|oYH>Lo)0l^O>-xol~(EKVJ3rNS24
z9dkv~s(>lPQk3Ln09QFxw%g1t9sr^?^6(^g8aeXXMA};P!0EWq{y~8cuM5Xna{#}@
zNoNIhscqK;61aD+t$!CC52>u<$iF-)z`pMyEOACUMbz?8fa#=y1%rC^e(sj)DCcX7
z6006MA((j$?S^ZegK$QEmi
zbP!5+0|zo9dqX~h*a{>c;sF4hdrOS~0o#QmzQ_M_nvFHcEtcxN@#8_PGyCx#U9EHM
zf4W7Utu)al)OY`!qJ?}0l+J&{i>^b}GJj`uExX(CkNxoc5H|Ax?bQWA>T8!yU(?H$
z5%Pj@(M2L{w>NU#5
zsp<$~U^^@o+z*d`DN0aP)0wA+|
zFIAR#y@;$}wRn&f#VjN^j{P4?(JJmeHdY=Vv8l}iRJhax_mD*q?
zi~`D;eJYR};_Q-!wsT#WR!5WghR^YB`U1IyXCzcW&c);l>b>t4?q(EGhx8bn0MsgC
zWs_Vuos>fOtbi67en#T=%V4mY815K$Ke+wdQPMRC2+&m08g!0mB8E0rUHhVnCXl5^
z;o?aSp|xSW=vHd-s0_3CHcoglQ`TVwchFd
z?*X5Z*(hyq<
z>{hksc*@d>a_E+S2dD7-RoNXiHfgOU#}-B!7whg?3ybiM0|9XIm0%!9Arst&EF=M0
zdEc~GTX1o>7U?|fGzWs)kFCHsb;|6A{Eb`*Eif2C#5*y9iSLo|6YVazjZT$=ttZEL
z&F)dJ*v#tzr2|Rm*@X0%III=O^*nUwcIBq03YGuTl5SaJ6(<66+pobatMQSTbE^Qb
zr+6q~bxJG50W;>^BDbQ&q4(=6$U4@mL7t}Hl3*29$3jbe)CVwfrYqy;
z&!0ZYU-36W*1q!CKUstD*iGU`Cqk)9(W5gih4(z*iA5&a*Bi;klR@~)KaD13e`WMx
z@L8xu)0rGH)(}pb0YZ0m1QSc$qo4Ryb-?t;i8$GB_&rOBna??>0&?(l#8yy3exdps
zTZIZZG3;O?^SWzNhV6iW1+!fr=hF&FZ0$_S^+0tlyU8oZ4YcBt^(~fKRTMF_$10xF
z4I7;wLCUHnM2z6U{%^zq)Fr)uGfGvsL#;_-FDX7WAzLhzMY<#|xLq6$wlinaQS3_;
zB5Fa|GNGfiOA(7pVb<iCpV(E{n4-EELyaoiq}muR|^7ua3Y3+<(9PCVg->G
zbB}qVn2Ucut!R)(L`>XVNi&b{D%MBIya51xBJra2?vFz&
ztDBc=0|Ce1ug;awa5zJ~auQtu%Rh|LFh!q+dOY16DZAy5;xtFFq-*#%K#zbKj6d^l
zWz0ZRW7Ul#0PPQ*vqS$>-&<|nkHah$ZaaON;oUNnA|)CA!n~|9w8n7FgPO^7#Wyb%*b#;L*W*WK=i8xm#KH^~8caskKp3#T(YHJC96mh?
zJrBRj`%Z@GGnd8Il1(3bs47ixfjgGUHHPAB@`I9xAcTj38LN_Av@kY
zi)a9~uQ@EO7i4zts#tVi>o&NM{)e$GVqu!B#PxjQ!pc~BW|zcP>kvG+qp5L56GqTx
ze_|#w&elvq#QLybKGD%CR5TWPXSq$E%Nu5Z@;a`aH(B4vGXL==)re`pNBU^m3_U`w
z@u2vQg6HrWQ@`v;MR>f`#*vuR%mM0&nydzf`p_D><}mI7nZkwpt_svgk0iR3*j7vb
zo}&Ci`_y-P!`a(yVkw~7TCVHSHMjROhB7YA=7(5hf=#qIpk_x%AaH%6E3SkTn0kN1
zF#c0LK+fY!qH(Id_kX_oHS+_#yFsc~DnsTnMDaMTkLGDM@HF?1{m}i2Srt*PT7Ltz
zwrbecJd5agk`r+r|M}wLi((`fEP~1SETms9rmg~xJoUoq{K<4egJZs=q0TzsYdc=S
zQE?|<&3p<46|en~e%8-K?^gCJgvR39Kb4VMm%WG(mes9;ptzz((UBjW+F~n<%-`3A
z?}Z;?qHEFz+c(?ln4ijfO^i7tPd+|%`vbB|O%Y5*=Llu5dotXGCGN5E5DRmNDEkMK
zd;zb^dZFs?H$%mr$^d=1h$KlN8?@>u1Cod#d-yn=e3LP
zX787(=OT}{_=8dZqW!nxtbbC{JOl_gwFJpiCB5?zhZJQT^Mol-C*LR@VI;uFPv!!{@Tr30G8d937cZ`=UhlYIDo(pOB!#D-C(x>_3%
z>Z)l?fm>$FepvwE0ZTF|ngV>HmH=4sBi`z4Lsrc1h)C@WlS3lxJ|;8iJG
zUKdJUcZ7rk%zD}|_&D70Nr_=(a74Lgdy)*N?a{msvB}4#xbvW;Vt9jA!STat3kpR^0`?Fu4R(48Tu`q`VA#6GDt#h
z*SBVO6nmpd{Q!%Pod&2}EXzv}>}NVsN$SW3Ek#zxrrnv;+S}15T=^24k?u9?Ye1O&
zNL{X%Ay9>5DNT)0kCnqv`&&Z?LuYq+uN^=0X^`QyNz_@cBSVf7ThAK0o#*IW%JV_f
z!Uk4+J69bBW&h_SdkEX!;?e{z`1`b+<}H*y=?y5>))rrh;P#{ky=&rHbj9P`QS^mI
z@bUKK#3aZHX6bq`el}7nZxg91i
z#UNtNbPq!upFAFi-DMAt&W#Y_`;QQo<28Rrm
zoY<{;7y5kR6!f6!kC2rH!FS~)&5uIU9)9EXqoe*QsClrs2w#m6`V?$1bh7*+s6hK!
zk3~ey>X_#o6IAYpd}Pw_+A!?er9kL%g@}U(EIEwb(2O`xer7I!
zO?O`}laRAf2Y+C?tcR*{K|$raYaPsaW^>ewx`H4?T#PrNDX=#pyfXb(#sZWwRit?u}v3G}pEjD32gfjY4WcI{S72!8vDFD)B{NAAEUw
zqE@|YI>{kp!7t|_K+Aetb&?i^lc{RKeqXj&j-5Hat^^O=MbKpJ>;1M62!yVCAHP?_
z>!n(my4kZ17u|u!rKv+S_S)HE-#VYU-wve8A-L2kIdTq#ze(#w41c)O`1_o<#?18k
zkV9DQRuUk#Gy-52@lMptP)ZSPjRzj^&s|$kU^b*x0AWH&h+gMKdJHv3Frk1gwrTb@l)SulCvn;b4*#==}WDWY}Kj`M3eoM;F_D@&O)RtOOA}3Um}gEV={om
zYDcI8IUb=>(En{L&2QB8U|Jhh#!U|G_pf)U&hDD<#?IAD#kM19u`|J9U|v~S{(6!`
zo-wYjg9_;G&>Scc$F?~DMUm$Seko=IXiqyp$K80^Rf#0sb{Hszek|Pvx|{Akg>^Ks
z;qW_d9a93Ig8LbAAgGr8W)z7YnQ=MXC|zmWl4sIoTIb_RT>aJB%KT@YFFXloFrAPq
zpK;QVghn9I2`m7IN>@@!%6y{hU`aBNa>To5J%GHo{9;%LchKCtxwpH4Dv!?$ubnXV
zN^j**xkw0&(`7gStZ^ME!V<;JKj@SrRTl9aTVqhb(C|`FWe`?yD>>eyZoU{ZmR0C|
zXy>!?{YSZ>=D47(57c!~B_&_7uahGZ;`A6m90GwpBFcPy6pK%iSz)&0mYby)|ESf0
zz{*p15Pt+P%_()2O=Z!g7$A-Bf*RB5NBk{qZyK?)NSS^=n=1D?+lqSz!(^RP;YmOY
z9EX8Xi0{fqR>X!?ij6Yt35V_dB2F|NY{@vNlBd4E7R^;&dBE@v?r`m|s`!RYY!*1i
z!mkxue=JDVfPqxYGoTC{B;~17Ffl86Nko)+_4K$Y=FSn7(^=B8`NpY|}7AkbCCn?8Cuf&TJ}#rW@-yj|don$Iw%
z7J?jH_NM`F^&l9`RfAbQ^!9E=yEGE94t0Y?Y-t|7Z$SR=PoHlzGJ#pO-;#n9ZPblKab-FrjCjQ(A$UW(l8&
z38^h8ytdlRG#>alRn(^KCH^@}*H=3$pioeNzT7D&`Kqj9BSkqkWOu!cSe#c+vDBKC
zFG>%RVLgj&OE3(;oU0+D3k7z8lWMU*;76&$T$*K(nb@{<);10lMIfF{E-C<|{jJ5L
zdFj>(+|KV8wPc*5@(O9Y;iPBKDIG$ak8^<`hD5f`FY=Y+j*3+@;?12h>dT&0s`Fc#
zAFTsqHmrVg?{(qNrdWq(_k*H8563B!<)dG!d-Gp=Zv$4lTP1H;+F9&*H*nL-*U=G@#2HPh7F=$7iH@BG@pszV~y
z;%6`|H-S{2Uyp5WoFNT{EVo3-GR~2%v9I?sTx5T0^T9D9Rg~^p(_9z**Py8Tl3|ag
zGWGJRJFt;SYr~Qw@Qf_s_i_y7@Q-Pr_Olme4uwFsOvwIVcJ0xRLDi$~m?u=pM2sJ5
zwI;4@3zenc**Oiv?!U&$=PwO+=<+>~{1jk#D+Fpfl1*A+>nYv6
zBnc~X2USJMhg3N7?DqUXI|3&)wNGc@r!U!jskdf0ZjSfXSgx5GgMjyH{2e&QA@HMMz+}XlCw+AO>+z)@gojcP(a3ap
z-W3R+-+3TJ|E1Bd6aV9GHCbH5J6}v-P+kAp9+Ii(B@}%8KksMIEbj%oGo-Xl;Qtqg
z>;JQ{Rz&_Z|Gzd|@uPk#6CSoz?&Y9lHVW`_2i?VLK>r%yBEYO_S>To57Xi44mvHw_pA7u-B
zG)x8qlWB?H(x_$jb;!T&-}vwl_00VD?+H)X`Q0~WI+kg*py-yTFZ(N*>0igM2#=)7
z3WNx3Zwwf#^0Rbu#=xL&0`6~_rm#;dpiOPk(hjgvtpD>-N<`h{m4tM9eIxu&{F^s-
z!P3H$!|d<;*REuUe?i}9572*4VK~Q$_{?(P02}^~Z}`vkX;!q77FmVaIX&yVB0!EZfz&@$$OZqQ=BmHSzTe+-w8RFId;@0k_5D@mEx
z-e^dTP#vuh9Vah#+^XBREORf0V(tE!=+Jzb??t$m9PD>tJk;nCY(mnalowp!-Do+g
z8gXtUu%{J1+_Kj5zrb$C8Y@?&7--K)M}W2#l1)UHA-uqjO$u0+va6~l5^zopL)_S
z{7;!2;R*>`k8FMGN#sbWy9VBJrC^fk0n_mMac{FG6ujl6PB{=RL5K+c70t8w=B9Kx
zh*#f>Elb_x-TFVA5WS&O*~r)rG|s|Gm!8k~#7hf|+oPQTDY|!M?8Vhx`tCPXDgF2M
z{=LV;&dpM*>d2W}w0*OD!&(s^xleXjIBpRjSHJC>NzzM$z&Myc4(xWG^#GN-v-N*F*dl?<(E9%_h^(ecm|39kA(YP^i|!Edz07F>*k%hc1^BK>GLCULGfp
zKET%AqR2Gu^?Cvxs#^WOMvP&t!f(k&WwTp*d>YswZq+HC9U*WsMCqNcVn&81?O?mfUWA%x>sd|*OO*fq
z(wbU!pmbd2*b*618Vt<21Yp)(1kN45ZE$O;OKSiZcidLnvhWY?OjAk(4{xu;(4?b7
zp(5RY^4tNMdWEv@{4nqpIRf+h^O~sTCR%7Pz=0(IFVJm*BXkKm>?di%8VH&mfXgf&
z0PWKAl5N$?8a0obgkuG+7lC=umF}=k4_>bITP$yzdB9KUCY09_cBAF^`-b*4^V>V?dZiZGEekgS~B
z1dzA-myYJmp&kqrBj`qIAZyDEEiphIj{6RcyyU%_V^ale!gD_
zgqnxB`2_`|P?$aUx^Np{PCc(2LxH!(y*d0)bmM=|xXJOby|oyP<~K0PLzOqcO|(;8
zs;LI(GKUXH@)3_}wpqN}{6fd4}&mIN2w0LiN6P
z(0c(A!JKKAwRZwm0F-=D+CD&z(|p%B-HmIIIh`0fEGEDUk!MXzK+t|p3I0?J7cEEc
zc&P!tf+n2c8?es3@k&5Nf8+gw!>*GR(YXKh-DGf^Z@^StY@2>|A!Ai$1AK#MyP515
zp$bq2q^u~q^^4CjNTo
z!L3{|<~~E$tyiikIFm#E=%d?J+>%mqy$Um3nO?@~yLq%T-II}E9bA|vX9buw_b!Oi
z%Q+3HQFkxyq!lju8TIj(Vw(;|i7;PV0(GeAU|NW7>>Upm&$>K!^;*CGbTiw?$}{X2
z&OT6za~O-gwlf*kx|~P1Y}k_!hyAsx__4nKCOXy}P}~29)_C_TD{TpkIW;OcTPNru
zFXOErW1(D^pauQm3Ba6OPhhK0>oMX#P6k?O`z?=YZ4AjGoNLes5e
zS<@9^E{!x{(_t7YAij;ILTj
z;?JXw#_AC`J)@+B)J_+(k0!bhB0Rd03yrYO)(26FcW--e4lv^^;HNu5ODzn%`5OY8b@GEYzlwhNa=TC=U^+}r#vD9v$h+2>
zcy#-z1vNIG)N(ThmjWNkI>798xO(*Du*Rn*9|w=^*-&p`c2Dg*_4&$wM(O|5#Lf7n
z9VW+lw;9y@?_-lTgFZ;C!377v
zX*S(ckC95Gb^W%Zq(`08LY>oT?Yr>K?e#ZgoazVF;;JI*g#RCxW!lR9-lOi-6H(3k
zljCuI8i&hBJyQ|eTNAS^diHYu7@@xDG-PLa-))tSrm>?J(k2~l^Q`Fkt|$!%Pk@R7
zjAFC#4by;Qj7*86+Y+{EE62y4NAUt_5
z-D>q$qC=4;j^-rGbl9f}V5wT_%(UqYjs=A^bz6QP&vhZXcj6;XF@h8dxwbX#*k5Qm
zGU^9}CTf$+VKU2B*cPf3BXR2U5)0&3<;%#Tprvx<7~LS5^-;aI&^v;dh;1g_ATO?v
zA%9Io(?W2RRPr^|KLBr9C`rECM5+@*j
z%~EGj+vB9L2B4vpy`s2tc)rCi3;PY<@gykET;DRS)Ww>&&+cJYS}4@~B>4P6L9xW^
zi5H+PVVqveOtk;tsP!5(NXid8m%FzdGo}ths@(R04+a-Wc=~h>_#Aak_f4$y93N#b
zGjFm2=Hys~S!&Ku9PS*^+v5!Ie%jtsAb1Lb>#QWgRk?JAwQ&O&i*~je`EZI*AK6%{!4c-JaC7k;V!c@oX5Rvn1fz
zxUkh-_KqJw8%;$}@x7TqV(*6%L#9QUQ+UisY-#
zqrh)77r8G<#R_XORp$!pZq;%5)Xx>9ey(EDRq<$feDu^9=Lnxbj@C)QPW@M^BV{SC
zty*+t&IsMIUdhoC2Z-M9hgee0k}G_N-ju-s8eKxFJJ@z^%56x;w7=Xw%;9srhfP(3
zZf=5nVq(et0RYT0C7~0UEm`6gxTVKla0G5l5`Y}Vkya!5g&-OYZ4LLLO+3u45$T!N
zD~+a`d{3i+5%L$P#D
z_zv(P%u!+@J9ej`r#VzJi-ZGF+aJ5sk%zjQ8~Lraw23Nm`sXfd>C0`!bkZ)Q`@F#c
zf__jGy%oY29sqDeon~F+R>UDxk4Ou!9uN^znDuAsEPuY7GWLn$;L^8%yq%nv6f#oE
z(WGgy2;TD;{bWEnWGI&2D236O&xv>+!IZ5NrQ;)nnnuG)fYtG!=|NUZXKQg#?j(+4
zC(dfQV23>ro)3Q3=-{?$vD%yi+IU8RC(4cPLK+ADnu8{(tTJRxW2Rd%iy$U@mbwgj
z4`vz(ocszu-{?Naz7sO#8^}Htd%znA;K?KV+ccB8c31I~s9S>CxjKYU5=WXX%*AL!TDIOr
z%0?uhix%~JIJfC06zds1LEi*wiyc;b!*3x*cfSU!d0TFYXMdRP39a{Iu_UnOl~~Bk
z=1;m#Hb*vB6&u$rL#<$bxS3y;mX+|-q@@HG
zvb&8F5kCIo%@5P58LQe9U$#%|2p?DP@4$&bl6|%%TE+Za{CMs>3!lZwTO-fsQ-d@>
zjdDX!>S@l@EsaR2%e>EJ7rbhIG5gzU)UhPSZ5JBPDc+FL%xk_6ug)f3z+K!?LeA9O
zn$aou>+@5YPI-TESP>8qCTd`$10@d%9@D%k{~PTyatR
zLysWb=$=C4E7>Ud8iRc!l7h^KQsOJ_%g2t51+?fxdI0p=c~@n0NEC5j@?Gx1JVerW
zdC0lC^R03xmz|*wVF+7wLFPNr|78H5oNqa_XoH9}h8Ny#r?Uo6slwA%>uOx-4x1w?
zL7TH5kRPOGXHjL4Q09e(T=nT36$F(7=YEbE!|EcD0ij3(hb>6XKec5oc#YvUNe41H-~~PU
zk43l)Uy9zusrmY-M$TDPq4_K9Pc|ZdQ}#F(oA^Y!L7|c7alhlK^kCfLy(J!qaHSE%
zl6%r3v5E4DQq}o!ekG#CAA56MxkY^@*y%pNohBqRGf_TX2MX1J
zihNq?rBM%xNPXLU?AHhUogtIHw}MCkD4C&2&h(+m49P=~je-HwV78MXGHzm_90Ax<
z!HDbE*M!>Jc{(E9|9i^0Sisn4fOuH{Q^53w^)-CnF=)j=s5n6Us>^!UoX{;MGr6bNH?{plN^_kw
z^IbGR6QOFg?jZ|EAXnOJxoaD0~D
zS~J8td6=1~#y6JhD)YNXp=bAY1jr&10r`%&VmX%&XvVVQ%ddw*U*=@H`(oYwYHoU3
z+|fEjHX-St6y|ZTXl8UoSC^7$R7!&{cgQjM%Z4a9i&gu+Sr^Br;yb`~tYhKv1;ded
z@>j&B-2^F$*|JDxp>{I}G`25RkhFVhZJ9n;WM7AReTK+$uUc$zpW{Ha{akwYR8Ib7
z;{t?!XR{AMetEJdRM=7q?;dCS#9|h2&!mELM2Q%M>D9}v(&!|
zkLJVdML#qOCgF*AF5x)W@uiS>>mDf1-5J^rbvnv@Z=Fl7?`qTM{emed#3W12uByEH
zR>NrQYJ$8UXgR36coW^j!DI0nnA}>cJjdq?Y)zDgH6<@$CUKFkDOYUKoM(H+un`&`
zq}YfcC$)qiOlHoHDd;2@{wl!hggjPhk~p4okh}PO68ShX;1N(;V#dpa`qKJ!$E%t*
zQYj6L&+EvMnyf+0#@XRtvKzVGOsrRx&YPWXD0phhTuOP?85SXFSu)EQLMO@#JkA+`
z_xpKwStWyrym^4hmQjqvQ(D!ypnYqbvpu4V8^y%p=SGFHLpH{|>&i)RpB`OLhXAIV
zpF38{kl4|@k{}3y>Ajof>L6Red0Uf@=SHJmLuK}Ns!x@D$!E+Blz3))=5u8SHCxUs@U~ZSK&9a8
zzzs*KC>)TG?<8$hd`XC&>A(nnZ)bp7ri8M+Ja;))`m%RfiEoFXfE%^PJr^h>(q}Mc
zlM(rDC&FHLo|sUQ-VykP!{Te$^;QB^M^EeT4@y;+PRlWfogh`+AWJxKdB61e@yh?U
zMV9@cZEuneqFf58I{Gq1W~Z#Ve#Q?LZda{+G)Z?^P^Gx@c+ttHFa?K}hlouUbKRKx
z9Vxq18t9X8T^g-?v18LPStn^Fj#2hj4@R6_P*U+<7I?@n9;7@p?RTrc7%DYG>H&6v
znl?q?{w@$a`Umkf6WOWc85C!Nva{ZNy|)mCQeCZoA}-~?Q)vJdQdQ>YhvA-c{3sZ{
z*Sv7=#E!8!@EOz(8i=jlEKr6|3JCN4$UB9;-7FcA)tRqo=5SFS-d0U$cTjFEF#c%Wnvuhd5%S6pW`>=BrfnV?qz^T^5H}$C^=VF
zBPtMO`$Jn2ie3NqtME;MY;Nx?v2X~Ssk-G}__N~k%zm?=#4NV?L_GigH9^(%>*rWM>996~8I>c%%>ef9o&N#@Z%FZ<6wE`6*KKPId`230d1D-cQO0%^A$J{Lw-VO`*8)
zFxtLn#)>}gA|F?7Ko4kDno?)u3a5cT76JO~5oLlvmV5XUe&IO_WSBb~wrY#A)Cz=}
zbZ>K@u`9qXJ*=KVaBkM1Elg!sYy(l}0kDJMz+b@dQg|?UQ4tlnoeuB&ns3NN|(%I#3{EUrR(S?pHS*Mclf`u05H9a@M-S~g0tzNGm4=DRrG>>=5
z>400z4eM`kN%5OKW7F-R{xaeHmMt5mgIJHy2XqWyPB%6|Oh4NJ-W(C0+gX#{wyktSPhAnSn6G~!srn$+Q_Q-!q80Qxx`4lm@^WX-
z&~CuL5JcTC5JgtwQ1Zmn^;>#>QbN`Zf4t$;rtF
z2g1d13&9nPOF8Prhn~O@FYaB`kokJw>bEYB{U2#a_S;11!0c0%w?|WGqKO3HQvbX8
z7@nQGynz7n&0v;RRD642eg(8Zh092+29$QC+~|#?^@E%K#(0BF?5#m;dZ>U+WTUZ1Fw@B+ZIY^G8-HHiB
zl7N7s7)X+HP*EfcDj5nTDXGXsu34u$-}~mx_vXj&L%M;wb?e@9_t|@`wbx#5
z_HM;?7r|tu1M3du6{v_u#cc6<`HjOYO0uYd
zFCHdF12-Ps0#Rsr?=?Zfm(DbM#kscekF$F2o)Y)3sSH&2LZvh8woyr{EN(}huF4BV
z&WCv>LE?rdD_V?elA`aM<*gU?eYW0K1CR8hS$Ypm*1MkY;^DPx-@M4$_(SL|C+G7|
zdwT1RPhUa_x$x>!&K3)+O%6dvnx=6sN?eAnr=^mQ8^562JbBkuV6GS6IXj516JOlb
z9bB?|_*L~21wQ@KNBh(Kzf-hF3_8F^k8eM*^-27cvIo|WH`7wugcn(NulwoH0s`uMw)K4WG{d|6z>MfG9JS?OzFqD%oyDgIJmM%?s^kORn7zYlY`*PfyvpOTB0wHcvz`r{$#CzfMfO*GPZPjiRJQ>Xc|4-6gIl9`}Vq74Mmh$8=}w0~V?nry0Q+17uq
zE;BFKynUL6&Bb<`%Yl9BL~$5L^Z+Bn1xLCZAouFb7*26%{k$O}PQSd!LGviT%nRC@
zRa3e+f=fdDW;BNS8(pv6`Mt7nUgo(mbVy;(V(sDX6Ef@@@>MD|dA*vH69@#|4woa#
z%-VIPyPLM`MQbegQt^v2oo_e-W3@w6d`l8Ak4h?MHRW
zKP{Sn#j*dBO2pIiT{%R)XA~x*cjVgbz15
ziETRqa8>PsEp2q!wihD4E`h@t4-C1$H^@`x^^b`s-U!{gVdnF|kd7P8Ex<5F-2K7HT3j**~Q$~ugm3MAJ#+qsV?pqvy!4oS#mE89#Q&;$?bK-
zGYVfD{{CTNBB6m)hA5!ofMk2O;*?Szm9X%E;qwkP952GN~ZO~tcvrZS7%&4
zV6=g9kr=d0eLh^GF0*gLoZy#JWw(JmOdDZ4b$Q!EZt)9UuFd8?HI99=_7rc`E%?*Q
z4(Q?ll&w9}yiV_kB0Lfi(8SnU%u+mI$Zb$+-*$;@x5zcsisSzh2GKN;0Ao8_pN5zT
zg&d+1TcT=k6%kN~#qF#5_toQ<3pdy-_pJ={kBi&V2Fr(xVpom_l*ghpud%TDhqsp(
zQX?<95YHbRr$}e9-Em6|MJME?X&YoOc^ZZXKZ&`ub>C(wDXB*DHtSF3)}Q3(SM2?A
zY+6zVwb}CMrz@{`{xlJRJR$Ou2}l09LO&fsdWH>p1_pLMzo!4<#x%d@Q5!-sfFLEm
zRgd2o#f`?dIBl;lz5k|BY=ZBme4X(<&3SWwio(P2rcu>+oy%L(wSrgs2hjoy`oqeF
zyDa+}sx_U)nr8oD+Z#9i!)#Y522B$C8}2(^oVBNNE2MpH)saSm4eE=1Umn*`ZaUQ;
z67Sg6+pMOPtgTb@>FLqeV}H_0W>;{P-H2&hkTv^_{|v=lPqO%?g&1b~+zA(Q&^c#&
zX#{u-2l(wy7~MJl;zsjXCEe(lb-l5-
zsBdNj<{-%41XGE@om)5An@II~u1PUt)i@J#8FEm`-C&3gWQkA==6T!@m1ga@42
z00u>lp`5Qh1v~m5wY-KkH6HP=VF{qtoiw
zGj-<7=XvEYVq=Xb`}4PRRaI5Ib_yrePg!J7oDKY>I0gRTs3lvDRV^;jBhHMF#(OeO
z8B*wd>>MB-T9so?%LSQ-!9UKM6a~%;Tc{u3k&2#|@ncniAYx8HsZil08-VT{3!@`9
zLqkhUpL>iT3zALq=g&SQogNW(yGm&
zW%jSRGW2Jir9G4J9O^gpnU5=oT&kNCjFpfECdgsfGfN+j$$4Ff5N5HH&M=pu;
zSz|=8&S4;n#pS~j`j?HBp8vD*mqRa7llD^+az*Lw3EFp0My4q}Jt+o7%;tEl5u^Nl
zF})Y>#XM6^J}_i0F?I83kZ@-A5z+byHhJ+b1O6TCF-MR8ady?6IoZ?9R=VHP{u_$3
zFG45&P7-}cn&p^Z{Gt#!%{j-FA!D;^JWqAu<6Vl3Q-R;P8S)1YytsOqa;EQpPH~_9
zL&L=U!T%D6ME4sIq4y5icQQBheY_dJiJ6?$JYhnpeokBaMHG2gepl^L8eGszG8)ntZCpzyBvH<4$DbX0qi$UB;$=)sOob+SnL9iF
zvxZ@MiBY~l{JYwVLP>rKL75lg0Fj;%#(eSog6T{}qU%y?w1K6LFyt9I-x%A+G*#vw
zyU}=p9En2lzzTaDj&eQ$OhR?C`IC{kd*KiErFIMXt*m<%+XrGx@%?2#WL$C&Q1@73
zsZ{}|GyD)Lp8%V-80KB%XM>6*4>G@V2&{3iCr~B1KzlOhgkh!JewnI|k?Pgo(TYFb
z{%;ZTO*Xka%m3@~!gHWKBkKudbKqz@KuB)Ld-7{pQG(w;J~_7vUW)3r-yTEDqYKg2
z#=IxQt8(Hi_{dQgRzpqeb6LpIOVEhm`P8Slpr9|x>f_P9LktZ(#>At9t91U4m*TsJ
z1SJR>Bp4vR>$T;|o*wzcqmSj+0d58}9f0
zVPd{awuhsB!8REplO68+mSEj|b|<~Y+z|cP%IH{d&qqr2mZLsQDNL7y&0T>@e!ZFV
zm1EEO1qJVp!9#|0ixIm=1H5fnr25WN;91*GlE%l0FIm&sS$4yyY)RvKzB2>0Z3OBk
zarE^aR8Vkx)68;C>lU+F<9N5zVO2K(IZSdjY1)R$z5wEz5LGFb%T0MBL!oVN3y)P3
zw`Yux8KQsI5`CGcKq4AI)*J0ej5200c3aK$f@Dp
z=1`3Se8$KH){k`T*`9tmA_m^5`Xcyg;H)3#gyr$@&^;=T7D(O%qzzO+OLM^5SK
zJ%`5bomVIkR8D*aCtce=Z4_!ZLlns?`0Sv;>A1G)_6y(z#V6zSbcj@oq(_H$c5E4e
zG)${|(h6~+IO0wd87R5a=8~2j@#QD6WDF79E5`%j6-hd7+{m3(d`~P#ozJd4x)Mb;HPxa`7xi>#z6ZVU+lL(JB=mseyg+JM0b#xoBvc#D
zEB?3&_`a`*rw`hh7bt#Ik>l5M|!3HhFZ6E#NXhK{zjD%7g&c;;rMj&IIc)({D24<&b9mbY}TVH?E5lzPb0
zSi{Oq^b1idPU+42cQye}$2(a5^2ph#U1sp=OKu}dt5dpZ%~lEu3W)$X1lm)Ol}D!D
z_FT=n=3M{CLOR?}oNHkcP~MJS^#o|$k}`3X?<=FJWa&1OUYa!eWDQL
zBF?fLof~ImNnkDW+IQ`;8UfoEzee7#0xB
ztONCb?I9kFFs5ciTW@6f7}@fALgJzqza9xY^1uZbx&Wjv-y(OqOHUxk?v#VU#5{!f
z{2biB-*pe^%lh%PualCtE_DpaSOQwPD-VjKYlG$IPF5T0X^OygGa3>rmK0M~m_2{S
zk6fbUmz4I$Q6PC2qliGe97CKl)Y0K|0Rn|EL%4xTDL+220CDaC!BxEdmV9`^ah;ok
z>R-3{Jx^`1)#2M#^L`%1`HJHCJ6-6kdjEY}`TmP_oCn^dvaD~){QnoS0Oni$kA0He
z&$A6eidhM>fD2-}1uw$G!&QwYMOyDA`M1`89f!9{Nqv!Tf?R{|OtK
z;Y8wN4C|6seWpA7%AS6Fd{5cU+)S4mKsY+YBo*sN(-K%r1w^{-=w(hMi7g(`aI`Mf
zO_deRE9k52cpaE1Ct<6WX)W>d5_ulkeCuBoSb(6NpHYDxv-V5Vd0+fBTk0bea4l1M
zxL!%Xa2Y_DFnnw+gn}0SI0J;aBtg%MeMHEL_ckWIrzn_f#>Z$*sr93P+Abv4TKbb%
z2a7oavhVo{vh0#agy!2yLD4HyM+{R&AUi|fZKo;Vx9bfrV(S=sq
z@av5!0%0rt-PL3^FueI9eTd;jX`oh7Y*~I{7}Z@m=TNlJ}W`I2U<5!w3O%ye!Se
z{Gj~CL8FtYmJx!vQSA&OkL2FAuWv#b$vi_&Z?>qLbod~aa>S9I!A15eM^d3Q82}_!
zMRfU}U;SeVo7LndriK-+*b(cb;EZWxGz`vz$>!%Z5Z@5kqe_uBa@cA+3{`bP8CBEv
zQJ=H|S#(r=k}Ag4I*$T--@8fg?#Y&E(WdOb!{YgjP$x8oL$C6Oq)EN3)w5HHkqjD_
zB{2XhLmx^iY+BmUBo&J;m!j*h$g+W?!z2-jL333w+mwUeLZ+9eLR_tT3es)pM%F7-
zD=9k>BuAN8D|Vo{FBUG|Rgm;tAgztd0c1utW(@3q7|tT=-se8kS|^c?xTJGiuuc7?
z700DiE1D7UeYs>%IK=y>{p?1amuSSK!s^qw&Hy!-PiigfA4ae=+Cf2BU1}xzCE?6b
zGrDhHvj{rs6spT)r*OPP2d~-!XClxEKM`wy7*rN_K~aU*5t$Art2&(@EdU$r909Oa
zKwJ(#q$pHJwV>bz$a;bAf)?7PT8cBH{Z$*9+JXgb>>-Y)06TjJ>*%=vR*mq;|I^Q=BQw9gX#U=
z`D-$vMTo$oc{ED%$>HTd^r-DndPP4xg1~2WAI%lydFf6%P=bAGYjsQsaWd(Ehc5>W
z{k=8I+`@#?VoG%2$;TPZqQB?gHKhx=MIttXPlONJNz$ipLusWuZY`CttgPCzYw?%7
zkMtpXqglhJH`I>fIZbl5ygF<7!@AB_|IDcEhb^cM+|oFc3*fu!Z&+y^#c~VZWgfq=
z!plIVOY+{+Le%o%{q2<>3hCphXj6ntbuvU$Ut|@IMam8kU$}tQl4A=q<*N9I(x@CY
z$)RhL*5`2-X+gn!af6D}{u0KwlLADG;%iX23)LYvJxkn(Nt#j^HAXv^N2S#fL1Hka3F0{1M
z;*~xQ95MW(y}WArOT+gWJ?HuKmgZ(x(;=}pqtg*DJB_Oo3Ns53Mq}vNzgt1UAuE{|nr;85>fHAHVVb^N`_rJtCvMLb
ztUs!pE!lgydQQz=s39T2eJajQFLB{r8<0Q4QMg>X%%P(Y2
zNR(dkoE!4pB<_HGEpy@7?R=jzdasORe0B(AccQ-Q*r{-u@kjstwa1Az*o3c;{D6=ui0#B{*7W|A@Ymf^Z;7Zoeblyr8DsJ%;>K40IKR>
zddvd)iZJLh(Wy=^
z;jW7*fni;8sz#c%X@yQ&4vf_A%$X2eF}JNAk>
z;Tq{xL38;w7Otmp3p3TWIUr-sF*Da5i`i2c1(Gc@DXG?OvvQ3{rX_MX)ARYJ%2G$(ja;z
zC=cKkT!uJ*MfE!b%{*auS*#plvB3+$?~r}Qi+l(L^3fRDV3Z>^$G&9GB4!iKluv$b
zW9T`KMU1Z^&>Ly3P`_a7RaRc_f?ZlPXSi6h_a^aaL{NIM%Y;l|7qo;W
ztUfq;sSRBwDHJQo<