From b19af68bbc8aaa6c6f2accfb76608f4eda6fb9f3 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:51:46 -0700
Subject: [PATCH 001/187] feat: workspace proxy toggle + target allowlist
config roundtrip (#52)
Admins can enable a per-workspace proxy and allowlist proxy targets (scheme/host/port) in Management Client; settings are stored server-side in the workspace JSON blob, default to proxy-off for definitions saved before the proxy existed, and travel to the Smart Client view item properties where the properties page reports proxy state and allowlisted targets. Adds a pure ProxyPolicy (canonicalization + exact-origin request matching) registered in both plugin and test compile inventories.
---
.../ProxyConfigurationTests.cs | 68 ++++++++++++
src/WebContent.Tests/ProxyPolicyTests.cs | 75 +++++++++++++
src/WebContent.Tests/WebContent.Tests.csproj | 1 +
.../WorkspacePropertiesTests.cs | 68 ++++++++++++
src/WebContent/Admin/WorkspaceConfigPage.xaml | 12 ++
.../Admin/WorkspaceConfigPage.xaml.cs | 26 +++++
src/WebContent/Admin/WorkspaceItemManager.cs | 90 +++++++++++++++
src/WebContent/Admin/WorkspaceUserControl.cs | 12 ++
.../WebContentPropertiesUserControl.xaml | 2 +
src/WebContent/Client/WebContentSettings.cs | 36 ++++++
src/WebContent/Client/WorkspaceProperties.cs | 36 ++++++
src/WebContent/ProxyPolicy.cs | 104 ++++++++++++++++++
src/WebContent/Strings.Designer.cs | 81 ++++++++++++++
src/WebContent/Strings.resx | 27 +++++
src/WebContent/WebContent.csproj | 1 +
src/WebContent/WorkspaceConfiguration.cs | 28 ++++-
16 files changed, 666 insertions(+), 1 deletion(-)
create mode 100644 src/WebContent.Tests/ProxyConfigurationTests.cs
create mode 100644 src/WebContent.Tests/ProxyPolicyTests.cs
create mode 100644 src/WebContent/ProxyPolicy.cs
diff --git a/src/WebContent.Tests/ProxyConfigurationTests.cs b/src/WebContent.Tests/ProxyConfigurationTests.cs
new file mode 100644
index 0000000..ec29d1a
--- /dev/null
+++ b/src/WebContent.Tests/ProxyConfigurationTests.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+using WebContent;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #52: the workspace configuration carries a proxy toggle plus an allowlist of proxy
+ /// targets (scheme, host, port). Definitions saved before the proxy existed — including the
+ /// v1 blobs already stored by older plugin versions — must mean "proxy off" with no targets,
+ /// and a corrupt or partial proxy section must never take down the config pipeline.
+ ///
+ public class ProxyConfigurationTests
+ {
+ private const string LegacyV1Blob =
+ "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0," +
+ "\"settings\":{\"homeAddress\":\"https://legacy.example/\",\"hideToolbar\":false," +
+ "\"hideAddress\":false,\"zoomLevel\":100,\"rememberLastAddress\":false," +
+ "\"clearBrowsingDataOnExit\":false,\"allowBackgroundBrowser\":true," +
+ "\"allowAudioNotifications\":true,\"muteAudio\":false,\"isolateBrowserInstances\":false," +
+ "\"enableDevTools\":false}}]}";
+
+ [Fact]
+ public void DefinitionSavedBeforeProxyExistedMeansProxyOff()
+ {
+ var settings = WorkspaceConfiguration.Deserialize(LegacyV1Blob).GetFirstWebContentSettings();
+
+ Assert.NotNull(settings);
+ Assert.False(settings.ProxyEnabled);
+ Assert.Null(settings.ProxyTargets);
+ }
+
+ [Fact]
+ public void DefaultWorkspaceConfigurationLeavesProxyOff()
+ {
+ var settings = WorkspaceConfiguration.CreateDefault("https://example.com/").GetFirstWebContentSettings();
+
+ Assert.False(settings.ProxyEnabled);
+ Assert.Null(settings.ProxyTargets);
+ }
+
+ [Fact]
+ public void ProxyToggleAndTargetsSurviveTheSerializedRoundtrip()
+ {
+ var config = WorkspaceConfiguration.CreateDefault("https://camera-ui.example/");
+ var settings = config.GetFirstWebContentSettings();
+ settings.ProxyEnabled = true;
+ settings.ProxyTargets = new List
+ {
+ new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 8443 },
+ new ProxyTargetConfiguration { Scheme = "http", Host = "cam2.local", Port = 80 }
+ };
+
+ var parsed = WorkspaceConfiguration.Deserialize(config.Serialize());
+
+ var proxied = parsed.GetFirstWebContentSettings();
+ Assert.True(proxied.ProxyEnabled);
+ Assert.Equal(2, proxied.ProxyTargets.Count);
+ Assert.Equal("https", proxied.ProxyTargets[0].Scheme);
+ Assert.Equal("10.0.0.5", proxied.ProxyTargets[0].Host);
+ Assert.Equal(8443, proxied.ProxyTargets[0].Port);
+ Assert.Equal("http", proxied.ProxyTargets[1].Scheme);
+ Assert.Equal("cam2.local", proxied.ProxyTargets[1].Host);
+ Assert.Equal(80, proxied.ProxyTargets[1].Port);
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyPolicyTests.cs b/src/WebContent.Tests/ProxyPolicyTests.cs
new file mode 100644
index 0000000..50f600c
--- /dev/null
+++ b/src/WebContent.Tests/ProxyPolicyTests.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+using WebContent;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #52: an admin allowlist entry is an origin (scheme, host, port). ProxyPolicy
+ /// canonicalizes every entry - whatever the admin types - to a comparable origin string, and
+ /// refuses entries that are not a valid http(s) origin. Canonical form is what the Smart
+ /// Client view item properties carry and what request matching later compares against.
+ ///
+ public class ProxyPolicyTests
+ {
+ [Theory]
+ [InlineData("https://10.0.0.5:8443", "https://10.0.0.5:8443")]
+ [InlineData("HTTPS://10.0.0.5:8443/live/index.html", "https://10.0.0.5:8443")]
+ [InlineData("https://cam2.local:443/", "https://cam2.local")]
+ [InlineData("http://cam2.local:80/", "http://cam2.local")]
+ [InlineData("http://cam2.local", "http://cam2.local")]
+ [InlineData("Cam2.Local:8443", "https://cam2.local:8443")]
+ [InlineData("https://[::1]:8443/status.cgi", "https://[::1]:8443")]
+ public void AdminEntryIsCanonicalizedToItsOrigin(string entry, string expected)
+ {
+ Assert.True(ProxyPolicy.TryNormalizeTarget(entry, out var normalized));
+ Assert.Equal(expected, normalized);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("ftp://files.example/")]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("https://host:99999")]
+ [InlineData("https://:8443")]
+ public void InvalidProxyTargetEntryIsRejected(string entry)
+ {
+ Assert.False(ProxyPolicy.TryNormalizeTarget(entry, out _));
+ }
+
+ [Fact]
+ public void RequestIsAllowedOnlyOnTheExactAllowlistedOrigin()
+ {
+ var targets = new[] { "https://cam2.local:8443" };
+
+ Assert.True(ProxyPolicy.IsRequestAllowed(targets, new Uri("https://cam2.local:8443/somewhere/page.html")));
+ Assert.False(ProxyPolicy.IsRequestAllowed(targets, new Uri("https://cam2.local:8444/")));
+ Assert.False(ProxyPolicy.IsRequestAllowed(targets, new Uri("http://cam2.local:8443/")));
+ Assert.False(ProxyPolicy.IsRequestAllowed(targets, new Uri("https://cam2.local/")));
+ }
+
+ [Fact]
+ public void DuplicateAndOverlappingEntriesCollapseOntoOneCanonicalOrigin()
+ {
+ var canonicalized = new List();
+ foreach (var entry in new[] { "https://Cam2.local:8443", "https://cam2.local:8443/live/", "Cam2.Local:8443" })
+ {
+ Assert.True(ProxyPolicy.TryNormalizeTarget(entry, out var normalized));
+ canonicalized.Add(normalized);
+ }
+
+ Assert.All(canonicalized, value => Assert.Equal("https://cam2.local:8443", value));
+ Assert.True(ProxyPolicy.IsRequestAllowed(canonicalized, new Uri("https://cam2.local:8443/any/thing")));
+ }
+
+ [Fact]
+ public void EmptyAllowlistRefusesEveryRequest()
+ {
+ Assert.False(ProxyPolicy.IsRequestAllowed(new string[0], new Uri("https://cam2.local:8443/")));
+ Assert.False(ProxyPolicy.IsRequestAllowed(null, new Uri("https://cam2.local:8443/")));
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 602f910..5105828 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -29,6 +29,7 @@
+
\ No newline at end of file
diff --git a/src/WebContent.Tests/WorkspacePropertiesTests.cs b/src/WebContent.Tests/WorkspacePropertiesTests.cs
index 2b08b71..252d151 100644
--- a/src/WebContent.Tests/WorkspacePropertiesTests.cs
+++ b/src/WebContent.Tests/WorkspacePropertiesTests.cs
@@ -225,6 +225,74 @@ public void Build_Changes_Fingerprint_When_Admin_Edits_Definition()
Assert.NotEqual(before["DefinitionFingerprint"], after["DefinitionFingerprint"]);
}
+ [Fact]
+ public void Json_Blob_Proxy_On_Emits_Canonical_Proxy_Properties()
+ {
+ var id = Guid.NewGuid();
+ var config = CreateConfig("camera.example");
+ var settings = config.Items[0].Settings;
+ settings.ProxyEnabled = true;
+ settings.ProxyTargets = new List
+ {
+ new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 8443 },
+ new ProxyTargetConfiguration { Scheme = "http", Host = "Cam2.local", Port = 80 },
+ };
+ var source = new Dictionary { [WorkspaceConfiguration.WorkspaceConfigKey] = config.Serialize() };
+
+ var properties = WorkspaceProperties.Build(id, "Ops", source, FallbackHomeAddress);
+
+ Assert.Equal("True", properties["ProxyEnabled"]);
+ Assert.Equal("https://10.0.0.5:8443,http://cam2.local", properties["ProxyTargets"]);
+ }
+
+ [Fact]
+ public void Json_Blob_Proxy_Off_Emits_Disabled_State()
+ {
+ var id = Guid.NewGuid();
+ var source = new Dictionary
+ {
+ [WorkspaceConfiguration.WorkspaceConfigKey] = CreateConfig("example.com").Serialize()
+ };
+
+ var properties = WorkspaceProperties.Build(id, "Ops", source, FallbackHomeAddress);
+
+ Assert.Equal("False", properties["ProxyEnabled"]);
+ Assert.Equal(string.Empty, properties["ProxyTargets"]);
+ }
+
+ [Fact]
+ public void Legacy_Path_Proxy_Is_Off()
+ {
+ var id = Guid.NewGuid();
+ var source = new Dictionary { ["Address"] = "intranet.local" };
+
+ var properties = WorkspaceProperties.Build(id, "Ops", source, FallbackHomeAddress);
+
+ Assert.Equal("False", properties["ProxyEnabled"]);
+ Assert.Equal(string.Empty, properties["ProxyTargets"]);
+ }
+
+ [Fact]
+ public void HandCraftedProxyTargetGibberish_IsSkipped_AndValidEntriesSurvive()
+ {
+ var id = Guid.NewGuid();
+ var config = CreateConfig("camera.example");
+ var settings = config.Items[0].Settings;
+ settings.ProxyEnabled = true;
+ settings.ProxyTargets = new List
+ {
+ new ProxyTargetConfiguration { Scheme = "ftp", Host = "files.example", Port = 21 },
+ new ProxyTargetConfiguration { Scheme = "https", Host = " ", Port = 8443 },
+ new ProxyTargetConfiguration { Scheme = "https", Host = "cam2.local", Port = 70000 },
+ new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 8443 },
+ };
+ var source = new Dictionary { [WorkspaceConfiguration.WorkspaceConfigKey] = config.Serialize() };
+
+ var properties = WorkspaceProperties.Build(id, "Ops", source, FallbackHomeAddress);
+
+ Assert.Equal("https://10.0.0.5:8443", properties["ProxyTargets"]);
+ }
+
[Fact]
public void Source_Properties_Are_Not_Mutated()
{
diff --git a/src/WebContent/Admin/WorkspaceConfigPage.xaml b/src/WebContent/Admin/WorkspaceConfigPage.xaml
index 5bc4a4c..982ac82 100644
--- a/src/WebContent/Admin/WorkspaceConfigPage.xaml
+++ b/src/WebContent/Admin/WorkspaceConfigPage.xaml
@@ -87,6 +87,18 @@
+
+
+
+
+
+
+
+
diff --git a/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs b/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
index 7084881..585aa8c 100644
--- a/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
+++ b/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
@@ -31,6 +31,9 @@ public WorkspaceConfigPage()
chkClearBrowsingDataOnExit.Content = Strings.ClearBrowsingDataOnExitLabel;
chkIsolateBrowserInstances.Content = Strings.IsolateBrowserInstancesLabel;
chkEnableDevTools.Content = Strings.EnableDevToolsLabel;
+ chkProxyEnabled.Content = Strings.ProxyEnabledLabel;
+ lblProxyTargets.Text = Strings.ProxyTargetsLabel + ":";
+ lblProxyTargetsHint.Text = Strings.ProxyTargetsHint;
}
public string WorkspaceName
@@ -123,6 +126,22 @@ public bool EnableDevTools
set => chkEnableDevTools.IsChecked = value;
}
+ public bool ProxyEnabled
+ {
+ get => chkProxyEnabled.IsChecked == true;
+ set => chkProxyEnabled.IsChecked = value;
+ }
+
+ ///
+ /// The allowlisted proxy targets, one origin per line as the admin types them
+ /// (ticket #52). The item manager validates and canonicalizes the lines on save.
+ ///
+ public string ProxyTargetsText
+ {
+ get => txtProxyTargets.Text;
+ set => txtProxyTargets.Text = value ?? string.Empty;
+ }
+
public void ClearUserControl()
{
txtName.Text = "New Workspace";
@@ -137,6 +156,8 @@ public void ClearUserControl()
chkMuteAudio.IsChecked = false;
chkIsolateBrowserInstances.IsChecked = false;
chkEnableDevTools.IsChecked = false;
+ chkProxyEnabled.IsChecked = false;
+ txtProxyTargets.Text = string.Empty;
configError.Visibility = Visibility.Collapsed;
chkHideAddress.IsEnabled = true;
}
@@ -195,5 +216,10 @@ private void OnZoomSliderValueChanged(object sender, RoutedPropertyChangedEventA
}
ConfigurationChanged?.Invoke(this, EventArgs.Empty);
}
+
+ private void OnProxyTargetsTextChanged(object sender, TextChangedEventArgs e)
+ {
+ ConfigurationChanged?.Invoke(this, EventArgs.Empty);
+ }
}
}
diff --git a/src/WebContent/Admin/WorkspaceItemManager.cs b/src/WebContent/Admin/WorkspaceItemManager.cs
index d99af29..60f0f15 100644
--- a/src/WebContent/Admin/WorkspaceItemManager.cs
+++ b/src/WebContent/Admin/WorkspaceItemManager.cs
@@ -133,6 +133,8 @@ public override void FillUserControl(Item item)
_userControl.MuteAudio = settings.MuteAudio;
_userControl.IsolateBrowserInstances = settings.IsolateBrowserInstances;
_userControl.EnableDevTools = settings.EnableDevTools;
+ _userControl.ProxyEnabled = settings.ProxyEnabled;
+ _userControl.ProxyTargetsText = DescribeProxyTargets(settings.ProxyTargets);
_userControl.FillContent();
if (configError != WorkspaceConfigError.None)
{
@@ -158,6 +160,8 @@ public override void FillUserControl(Item item)
_userControl.MuteAudio = WebContentConfiguration.GetBoolean(properties, false, "MuteAudio");
_userControl.IsolateBrowserInstances = WebContentConfiguration.GetBoolean(properties, false, "IsolateBrowserInstances");
_userControl.EnableDevTools = WebContentConfiguration.GetBoolean(properties, false, "EnableDevTools");
+ _userControl.ProxyEnabled = false;
+ _userControl.ProxyTargetsText = string.Empty;
_userControl.FillContent();
if (configError != WorkspaceConfigError.None)
@@ -240,6 +244,16 @@ public override bool ValidateAndSaveUserControl()
var hideToolbar = _userControl?.HideToolbar ?? false;
var hideAddress = !hideToolbar && (_userControl?.HideAddress ?? false);
+ var proxyEnabled = _userControl?.ProxyEnabled ?? false;
+ List proxyTargets = null;
+ if (proxyEnabled)
+ {
+ if (!TryBuildProxyTargets(_userControl.ProxyTargetsText, out proxyTargets))
+ {
+ return false;
+ }
+ }
+
SetOrRemoveProperty(CurrentItem, "Address", homeAddress);
SetOrRemoveProperty(CurrentItem, "HomeAddress", homeAddress);
SetOrRemoveProperty(CurrentItem, "RememberLastAddress", WebContentConfiguration.FormatBoolean(_userControl?.RememberLastAddress ?? false));
@@ -267,6 +281,8 @@ public override bool ValidateAndSaveUserControl()
settings.MuteAudio = _userControl?.MuteAudio ?? false;
settings.IsolateBrowserInstances = _userControl?.IsolateBrowserInstances ?? false;
settings.EnableDevTools = _userControl?.EnableDevTools ?? false;
+ settings.ProxyEnabled = proxyEnabled;
+ settings.ProxyTargets = proxyTargets;
}
SetOrRemoveProperty(CurrentItem, WorkspaceConfiguration.WorkspaceConfigKey, config.Serialize());
@@ -289,6 +305,80 @@ private void SetOrRemoveProperty(Item item, string key, string value)
}
}
+ ///
+ /// Ticket #52: parses the admin's one-origin-per-line proxy targets into canonical
+ /// scheme/host/port records. An invalid line rejects the save (same posture as the home
+ /// address), duplicates and other overlapping entries collapse onto one record, and an
+ /// enabled proxy with no targets at all is rejected so the toggle can never mean
+ /// "allow nothing" by accident.
+ ///
+ private bool TryBuildProxyTargets(string targetsText, out List targets)
+ {
+ targets = new List();
+ var seenOrigins = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var rawLine in (targetsText ?? string.Empty).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0)
+ {
+ continue;
+ }
+
+ if (!ProxyPolicy.TryNormalizeTarget(line, out var origin))
+ {
+ EnvironmentManager.Instance.Log(true, nameof(WorkspaceItemManager), $"Rejected save for workspace '{CurrentItem.Name}' (FQID: {CurrentItem.FQID}): invalid proxy target '{line}'.");
+ MessageBox.Show(string.Format(Strings.InvalidProxyTargetMessage, line), Strings.InvalidAddressTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return false;
+ }
+
+ if (!seenOrigins.Add(origin))
+ {
+ continue;
+ }
+
+ var originUri = new Uri(origin);
+ targets.Add(new ProxyTargetConfiguration
+ {
+ Scheme = originUri.Scheme,
+ Host = originUri.Host,
+ Port = originUri.Port
+ });
+ }
+
+ if (targets.Count == 0)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(WorkspaceItemManager), $"Rejected save for workspace '{CurrentItem.Name}' (FQID: {CurrentItem.FQID}): proxy enabled but no target allowlisted.");
+ MessageBox.Show(Strings.MissingProxyTargetsMessage, Strings.InvalidAddressTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Renders stored proxy target records back as canonical one-per-line text for the admin
+ /// page; invalid stored records are dropped rather than shown.
+ ///
+ private static string DescribeProxyTargets(List targets)
+ {
+ if (targets == null || targets.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ var lines = new List();
+ foreach (var target in targets)
+ {
+ if (target != null && ProxyPolicy.TryNormalizeTargetParts(target.Scheme, target.Host, target.Port, out var origin))
+ {
+ lines.Add(origin);
+ }
+ }
+
+ return string.Join(Environment.NewLine, lines);
+ }
+
public override Item CreateItem(Item parentItem, FQID suggestedFQID)
{
var fqid = new FQID(Configuration.Instance.ServerFQID.ServerId)
diff --git a/src/WebContent/Admin/WorkspaceUserControl.cs b/src/WebContent/Admin/WorkspaceUserControl.cs
index b799de2..c0cda36 100644
--- a/src/WebContent/Admin/WorkspaceUserControl.cs
+++ b/src/WebContent/Admin/WorkspaceUserControl.cs
@@ -114,6 +114,18 @@ public bool EnableDevTools
set => _page.EnableDevTools = value;
}
+ public bool ProxyEnabled
+ {
+ get => _page.ProxyEnabled;
+ set => _page.ProxyEnabled = value;
+ }
+
+ public string ProxyTargetsText
+ {
+ get => _page.ProxyTargetsText;
+ set => _page.ProxyTargetsText = value;
+ }
+
internal void ClearUserControl()
{
_page.ClearUserControl();
diff --git a/src/WebContent/Client/WebContentPropertiesUserControl.xaml b/src/WebContent/Client/WebContentPropertiesUserControl.xaml
index 65860e5..1f43abc 100644
--- a/src/WebContent/Client/WebContentPropertiesUserControl.xaml
+++ b/src/WebContent/Client/WebContentPropertiesUserControl.xaml
@@ -63,6 +63,8 @@
TickFrequency="25"
IsSnapToTickEnabled="True"/>
+
+
diff --git a/src/WebContent/Client/WebContentSettings.cs b/src/WebContent/Client/WebContentSettings.cs
index 1a72691..73757ad 100644
--- a/src/WebContent/Client/WebContentSettings.cs
+++ b/src/WebContent/Client/WebContentSettings.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using WebContent;
@@ -190,6 +191,41 @@ public bool EnableDevTools
}
}
+ ///
+ /// Ticket #52: admin-owned proxy state for this workspace. Read-only on the client: the
+ /// Smart Client user can see whether the browser routes through the VMS server and which
+ /// targets are allowlisted, but only the Management Client admin may change it.
+ ///
+ public string ProxyLabel => Strings.ProxyLabel;
+
+ public bool ProxyEnabled => WebContentConfiguration.GetBoolean(GetProperty, false, nameof(ProxyEnabled));
+
+ public string ProxyDisplay
+ {
+ get
+ {
+ if (!ProxyEnabled)
+ {
+ return Strings.ProxyDisplayDisabled;
+ }
+
+ var rawTargets = WebContentConfiguration.GetString(GetProperty, string.Empty, "ProxyTargets");
+ var targets = new List();
+ foreach (var target in (rawTargets ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var trimmed = target.Trim();
+ if (trimmed.Length > 0)
+ {
+ targets.Add(trimmed);
+ }
+ }
+
+ return targets.Count == 0
+ ? Strings.ProxyDisplayEnabledNoTargets
+ : Strings.ProxyDisplayEnabledPrefix + " " + string.Join(", ", targets);
+ }
+ }
+
public Guid InstanceId
{
get => WebContentConfiguration.GetGuid(GetProperty, Guid.Empty, nameof(InstanceId));
diff --git a/src/WebContent/Client/WorkspaceProperties.cs b/src/WebContent/Client/WorkspaceProperties.cs
index 0f473d4..1f55213 100644
--- a/src/WebContent/Client/WorkspaceProperties.cs
+++ b/src/WebContent/Client/WorkspaceProperties.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
namespace WebContent.Client
{
@@ -42,6 +43,8 @@ public static Dictionary Build(
properties["MuteAudio"] = WebContentConfiguration.FormatBoolean(settings.MuteAudio);
properties["IsolateBrowserInstances"] = WebContentConfiguration.FormatBoolean(settings.IsolateBrowserInstances);
properties["EnableDevTools"] = WebContentConfiguration.FormatBoolean(settings.EnableDevTools);
+ properties["ProxyEnabled"] = WebContentConfiguration.FormatBoolean(settings.ProxyEnabled);
+ properties["ProxyTargets"] = BuildProxyTargetsProperty(settings.ProxyTargets);
return properties;
}
@@ -79,7 +82,40 @@ public static Dictionary Build(
properties["EnableDevTools"] = WebContentConfiguration.FormatBoolean(
WebContentConfiguration.GetBoolean(sourceProperties, false, "EnableDevTools"));
+ properties["ProxyEnabled"] = WebContentConfiguration.FormatBoolean(false);
+ properties["ProxyTargets"] = string.Empty;
+
return properties;
}
+
+ ///
+ /// Ticket #52: flattens the admin proxy allowlist to the canonical, comma-separated
+ /// origin list the view item consumes. Invalid entries (bad scheme/host/port) are
+ /// dropped; duplicates collapse.
+ ///
+ private static string BuildProxyTargetsProperty(List targets)
+ {
+ if (targets == null || targets.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ var canonical = new List();
+ foreach (var target in targets)
+ {
+ if (target == null)
+ {
+ continue;
+ }
+
+ if (ProxyPolicy.TryNormalizeTargetParts(target.Scheme, target.Host, target.Port, out var origin) &&
+ !canonical.Contains(origin, StringComparer.OrdinalIgnoreCase))
+ {
+ canonical.Add(origin);
+ }
+ }
+
+ return string.Join(",", canonical);
+ }
}
}
diff --git a/src/WebContent/ProxyPolicy.cs b/src/WebContent/ProxyPolicy.cs
new file mode 100644
index 0000000..340c232
--- /dev/null
+++ b/src/WebContent/ProxyPolicy.cs
@@ -0,0 +1,104 @@
+using System;
+
+namespace WebContent
+{
+ ///
+ /// Pure policy for the ticket #52 proxy allowlist: canonicalizes admin-entered targets to a
+ /// comparable origin string (lower-case scheme and host, explicit port unless it is the
+ /// scheme default) and matches request origins against the allowlist. Invalid entries never
+ /// enter the allowlist; duplicates and other overlapping entries collapse onto the same
+ /// canonical origin, so the allowlist is effectively a set.
+ ///
+ internal static class ProxyPolicy
+ {
+ ///
+ /// Canonicalizes one admin allowlist entry (a scheme/host/port origin, with the bare
+ /// host and host:port shorthands taken as https) to "scheme://host[:port]". Returns false
+ /// for anything that is not an http(s) origin; paths, queries, and fragments are ignored.
+ ///
+ internal static bool TryNormalizeTarget(string entry, out string normalized)
+ {
+ normalized = null;
+ if (!WebContentConfiguration.TryCreateNavigationUri(entry, out var uri))
+ {
+ return false;
+ }
+
+ normalized = CanonicalOrigin(uri);
+ return true;
+ }
+
+ ///
+ /// The canonical origin form used for both stored allowlist entries and request matching.
+ ///
+ internal static string CanonicalOrigin(Uri uri)
+ {
+ // Uri.Host carries IPv6 hosts already bracketed ("[::1]"), so it needs no re-wrapping.
+ var host = uri.Host.ToLowerInvariant();
+ var portPart = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
+ return $"{uri.Scheme.ToLowerInvariant()}://{host}{portPart}";
+ }
+
+ ///
+ /// Canonicalizes a stored allowlist entry from its scheme/host/port parts (the shape the
+ /// workspace JSON carries). Invalid parts - a non-http scheme, a blank or malformed host,
+ /// an out-of-range port - reject the entry instead of poisoning the allowlist. A zero port
+ /// means the scheme default.
+ ///
+ internal static bool TryNormalizeTargetParts(string scheme, string host, int port, out string normalized)
+ {
+ normalized = null;
+ var schemePart = (scheme ?? string.Empty).Trim().ToLowerInvariant();
+ if (schemePart != Uri.UriSchemeHttp && schemePart != Uri.UriSchemeHttps)
+ {
+ return false;
+ }
+
+ var hostPart = (host ?? string.Empty).Trim().ToLowerInvariant();
+ if (hostPart.Length == 0 || hostPart.IndexOfAny(new[] { ' ', '/', '?', '#', '@' }) >= 0)
+ {
+ return false;
+ }
+
+ var ipv6 = hostPart.StartsWith("[", StringComparison.Ordinal) && hostPart.EndsWith("]", StringComparison.Ordinal);
+ if (hostPart.IndexOf(':') >= 0 && !ipv6)
+ {
+ hostPart = $"[{hostPart}]";
+ ipv6 = true;
+ }
+
+ if (port < 0 || port > 65535)
+ {
+ return false;
+ }
+
+ var schemeDefault = schemePart == Uri.UriSchemeHttp ? 80 : 443;
+ var portPart = port == 0 || port == schemeDefault ? string.Empty : $":{port}";
+ normalized = $"{schemePart}://{hostPart}{portPart}";
+ return true;
+ }
+
+ ///
+ /// True when the request origin already appears in the allowlisted canonical origins —
+ /// exact origin match, no wildcards, no path or prefix semantics.
+ ///
+ internal static bool IsRequestAllowed(System.Collections.Generic.IEnumerable canonicalTargets, Uri requestUri)
+ {
+ if (canonicalTargets == null || requestUri == null)
+ {
+ return false;
+ }
+
+ var requestOrigin = CanonicalOrigin(requestUri);
+ foreach (var target in canonicalTargets)
+ {
+ if (string.Equals(target, requestOrigin, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/WebContent/Strings.Designer.cs b/src/WebContent/Strings.Designer.cs
index 1c7b043..093525d 100644
--- a/src/WebContent/Strings.Designer.cs
+++ b/src/WebContent/Strings.Designer.cs
@@ -392,5 +392,86 @@ internal static string WorkspaceConfigErrorMissingBrowserConfig {
return ResourceManager.GetString("WorkspaceConfigErrorMissingBrowserConfig", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Route web traffic through the VMS server proxy.
+ ///
+ internal static string ProxyEnabledLabel {
+ get {
+ return ResourceManager.GetString("ProxyEnabledLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Proxy targets.
+ ///
+ internal static string ProxyTargetsLabel {
+ get {
+ return ResourceManager.GetString("ProxyTargetsLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to One target per line, for example https://10.0.0.5:8443. Only allowlisted targets can be reached through the proxy..
+ ///
+ internal static string ProxyTargetsHint {
+ get {
+ return ResourceManager.GetString("ProxyTargetsHint", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to "{0}" is not a valid proxy target. Enter a target with scheme, host and port, for example https://10.0.0.5:8443..
+ ///
+ internal static string InvalidProxyTargetMessage {
+ get {
+ return ResourceManager.GetString("InvalidProxyTargetMessage", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The proxy is enabled but no target is allowlisted. Add at least one proxy target or disable the proxy..
+ ///
+ internal static string MissingProxyTargetsMessage {
+ get {
+ return ResourceManager.GetString("MissingProxyTargetsMessage", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Proxy:.
+ ///
+ internal static string ProxyLabel {
+ get {
+ return ResourceManager.GetString("ProxyLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Disabled - browser connects directly.
+ ///
+ internal static string ProxyDisplayDisabled {
+ get {
+ return ResourceManager.GetString("ProxyDisplayDisabled", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Enabled - no targets allowlisted.
+ ///
+ internal static string ProxyDisplayEnabledNoTargets {
+ get {
+ return ResourceManager.GetString("ProxyDisplayEnabledNoTargets", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Enabled - allowlisted targets:.
+ ///
+ internal static string ProxyDisplayEnabledPrefix {
+ get {
+ return ResourceManager.GetString("ProxyDisplayEnabledPrefix", resourceCulture);
+ }
+ }
}
}
diff --git a/src/WebContent/Strings.resx b/src/WebContent/Strings.resx
index 9ed1c1b..c32f356 100644
--- a/src/WebContent/Strings.resx
+++ b/src/WebContent/Strings.resx
@@ -231,4 +231,31 @@
Workspace
+
+ Route web traffic through the VMS server proxy
+
+
+ Proxy targets
+
+
+ One target per line, for example https://10.0.0.5:8443. Only allowlisted targets can be reached through the proxy.
+
+
+ "{0}" is not a valid proxy target. Enter a target with scheme, host and port, for example https://10.0.0.5:8443.
+
+
+ The proxy is enabled but no target is allowlisted. Add at least one proxy target or disable the proxy.
+
+
+ Proxy:
+
+
+ Disabled - browser connects directly
+
+
+ Enabled - no targets allowlisted
+
+
+ Enabled - allowlisted targets:
+
\ No newline at end of file
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index 99e2234..1d760e0 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -103,6 +103,7 @@
+
diff --git a/src/WebContent/WorkspaceConfiguration.cs b/src/WebContent/WorkspaceConfiguration.cs
index 973991a..76a6259 100644
--- a/src/WebContent/WorkspaceConfiguration.cs
+++ b/src/WebContent/WorkspaceConfiguration.cs
@@ -217,7 +217,6 @@ internal class WorkspaceItemSettings
{
[DataMember(Name = "homeAddress")]
public string HomeAddress { get; set; }
-
[DataMember(Name = "hideToolbar")]
public bool HideToolbar { get; set; }
@@ -247,5 +246,32 @@ internal class WorkspaceItemSettings
[DataMember(Name = "enableDevTools")]
public bool EnableDevTools { get; set; }
+
+ ///
+ /// Ticket #52: server-side proxy toggle for this workspace. Absent from definitions saved
+ /// before the proxy existed, which deserializes to false - proxy off.
+ ///
+ [DataMember(Name = "proxyEnabled")]
+ public bool ProxyEnabled { get; set; }
+
+ ///
+ /// Ticket #52: targets the proxy is permitted to reach (scheme, host, port). Null for
+ /// definitions that predate the proxy or where the admin added none.
+ ///
+ [DataMember(Name = "proxyTargets")]
+ public List ProxyTargets { get; set; }
+ }
+
+ [DataContract]
+ internal class ProxyTargetConfiguration
+ {
+ [DataMember(Name = "scheme")]
+ public string Scheme { get; set; }
+
+ [DataMember(Name = "host")]
+ public string Host { get; set; }
+
+ [DataMember(Name = "port")]
+ public int Port { get; set; }
}
}
From ed01d984eb4728e18062b2fc54ef93bd74e7eb9c Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:59:51 -0700
Subject: [PATCH 002/187] fix: review follow-ups for proxy config roundtrip
(#52)
Register the proxy area in the verification matrix (MC-9); preserve allowlisted targets when the admin toggles the proxy off instead of wiping them; funnel both canonical-origin paths through one FormatOrigin formatter and one dedup shape; add a Proxy_Off_Preserves_Stored_Targets pin.
---
docs/verification-matrix.md | 12 ++++++++-
.../WorkspacePropertiesTests.cs | 19 ++++++++++++++
src/WebContent/Admin/WorkspaceItemManager.cs | 26 +++++++++----------
src/WebContent/Client/WorkspaceProperties.cs | 4 +--
src/WebContent/ProxyPolicy.cs | 24 +++++++++++------
5 files changed, 60 insertions(+), 25 deletions(-)
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 026503f..7e7346f 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -32,6 +32,7 @@ is not evidence the plugin never ran.
| Session state (last-URL restore) | #9 | `WorkspaceSessionStateTests` (record/overwrite per instance key, fingerprint guard makes a definition edit unreadable, reset, blank-fingerprint standalone exclusion, restore precedence recorded → persisted → home, definition-update detection from fingerprints) | See MC-7 below: workspace switch restores the page, restart resets to home |
| Configuration error visibility | #9 | `WorkspaceDefinitionTests` (`ConfigurationError` classifies malformed JSON, blob-without-items, no-home-address item, nothing configured; malformed JSON reported even when legacy properties would rescue it; `SelectVisible` keeps named-but-broken tabs and still skips unnamed/unpermitted) | See MC-8 below: broken tab appears with a WorkSpaceNotification warning |
| SBOM integrity | #25 | `SbomGenerationGuardTests` (PostBuildEvent keeps a quoted absolute `-bc "$(MSBuildProjectDirectory)"` component path and `-D true`; the original bug let the CWD-relative `-bc .\` point at the build drop, so zero packages were detected) | `.\build.ps1` throws unless `output\WebContent\_manifest\spdx_2.2\manifest.spdx.json` lists referenced packages; CI Build job shows `SBOM OK` and no "no packages detected" annotation |
+| Proxy configuration roundtrip | #52 | `ProxyConfigurationTests` (definitions saved before the proxy existed mean proxy-off; toggle+targets survive the serialized roundtrip), `ProxyPolicyTests` (admin entries canonicalize to scheme/host/port origins; garbage rejected; exact-origin matching; duplicates/overlaps collapse), `WorkspacePropertiesTests` (JSON-blob and legacy paths emit canonical `ProxyEnabled`/`ProxyTargets`, hand-crafted target gibberish skipped) | See MC-9 below: admin toggle round-trips to the Smart Client properties page; invalid target save refused with log marker |
## Manual host checks
@@ -88,6 +89,13 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
3. Expected log marker in MIPLog.txt: `Configuration error for workspace ''`.
4. Expected browser content: the tab renders the fallback home page. Open the workspace in the Management Client: a **red banner** appears on the details page naming the configuration defect, and the **Save button becomes enabled on its own** (no need to nudge any setting — ticket #27 dirty-after-fill); one Save rewrites a clean definition, and re-logging in clears the Smart Client notification. MC log marker: `loaded with a configuration error (MalformedJson)`.
+### MC-9 Proxy config roundtrip and refusal (#52)
+
+1. In Management Client, enable "Route web traffic through the VMS server proxy" on a workspace and add one target line (e.g. `https://10.0.0.5:8443`); Save.
+2. In Smart Client, open the workspace tab's properties page. Expected: the Proxy section reports the enabled state and lists the allowlisted target (canonical form).
+3. Toggle the proxy off, Save, and check the target list is still present in the stored definition (off-toggle preserves allowlisted targets; MC-9 pin for the wipe bug found in review).
+4. With the proxy enabled, enter an invalid target line (`ftp://x`, `not a url`) and Save: refused with a message naming the line; MC log marker: `Rejected save for workspace '' (FQID: ...): invalid proxy target`. Enabled with no targets at all is refused likewise (`proxy enabled but no target allowlisted`).
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
@@ -100,6 +108,8 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
| Unbounded browser tracking / stale async callbacks in long sessions (#15) | `BrowserRegistryTests` bounded-cycle test, `CallbackOwnershipTests`, MC-6 |
| Workspace tab losing the operator's page on workspace switch, or ignoring an admin definition edit (#9) | `WorkspaceSessionStateTests` (fingerprint guard, restore precedence, update detection), MC-7 |
| Broken workspace config silently hiding its tab again (#9) | `WorkspaceDefinitionTests` (`ConfigurationError` + `SelectVisible` visibility pins), MC-8 |
+| Proxy allowlist lost on toggle-off, or proxy silently defaulting on for old definitions (#52) | `ProxyConfigurationTests` (legacy blob = proxy off; roundtrip), MC-9 |
+| Inconsistent proxy-target canonicalization between admin and runtime flows (#52) | `ProxyPolicyTests` + `WorkspacePropertiesTests` (shared `ProxyPolicy` canonical form in both save and Build paths) |
## Scope notes (deliberate)
@@ -108,4 +118,4 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
ever refactored, extract the policy into a pure class first and move MC-4's backoff
assertions into the suite.
- WebView2/Smart Client UI behavior (actual page render, WPF address-bar visuals) cannot be
- unit-tested at the pure seams; MC-1–MC-6 are the release gate for those.
+ unit-tested at the pure seams; MC-1–MC-9 are the release gate for those.
diff --git a/src/WebContent.Tests/WorkspacePropertiesTests.cs b/src/WebContent.Tests/WorkspacePropertiesTests.cs
index 252d151..683b69e 100644
--- a/src/WebContent.Tests/WorkspacePropertiesTests.cs
+++ b/src/WebContent.Tests/WorkspacePropertiesTests.cs
@@ -293,6 +293,25 @@ public void HandCraftedProxyTargetGibberish_IsSkipped_AndValidEntriesSurvive()
Assert.Equal("https://10.0.0.5:8443", properties["ProxyTargets"]);
}
+ [Fact]
+ public void Proxy_Off_Preserves_Stored_Targets_For_The_Next_Toggle_On()
+ {
+ var id = Guid.NewGuid();
+ var config = CreateConfig("camera.example");
+ var settings = config.Items[0].Settings;
+ settings.ProxyEnabled = false;
+ settings.ProxyTargets = new List
+ {
+ new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 8443 },
+ };
+ var source = new Dictionary { [WorkspaceConfiguration.WorkspaceConfigKey] = config.Serialize() };
+
+ var properties = WorkspaceProperties.Build(id, "Ops", source, FallbackHomeAddress);
+
+ Assert.Equal("False", properties["ProxyEnabled"]);
+ Assert.Equal("https://10.0.0.5:8443", properties["ProxyTargets"]);
+ }
+
[Fact]
public void Source_Properties_Are_Not_Mutated()
{
diff --git a/src/WebContent/Admin/WorkspaceItemManager.cs b/src/WebContent/Admin/WorkspaceItemManager.cs
index 60f0f15..8382768 100644
--- a/src/WebContent/Admin/WorkspaceItemManager.cs
+++ b/src/WebContent/Admin/WorkspaceItemManager.cs
@@ -245,13 +245,9 @@ public override bool ValidateAndSaveUserControl()
var hideAddress = !hideToolbar && (_userControl?.HideAddress ?? false);
var proxyEnabled = _userControl?.ProxyEnabled ?? false;
- List proxyTargets = null;
- if (proxyEnabled)
+ if (!TryBuildProxyTargets(CurrentItem.Name, CurrentItem.FQID, _userControl?.ProxyTargetsText, proxyEnabled, out var proxyTargets))
{
- if (!TryBuildProxyTargets(_userControl.ProxyTargetsText, out proxyTargets))
- {
- return false;
- }
+ return false;
}
SetOrRemoveProperty(CurrentItem, "Address", homeAddress);
@@ -307,12 +303,14 @@ private void SetOrRemoveProperty(Item item, string key, string value)
///
/// Ticket #52: parses the admin's one-origin-per-line proxy targets into canonical
- /// scheme/host/port records. An invalid line rejects the save (same posture as the home
- /// address), duplicates and other overlapping entries collapse onto one record, and an
- /// enabled proxy with no targets at all is rejected so the toggle can never mean
- /// "allow nothing" by accident.
+ /// scheme/host/port records. An invalid line rejects the save whatever the proxy toggle
+ /// state (same posture as the home address), duplicates and other overlapping entries
+ /// collapse onto one record, and an enabled proxy with no targets at all is rejected so
+ /// the toggle can never mean "allow nothing" by accident. A disabled proxy keeps the
+ /// targets parsed and stored anyway - turning the proxy off must not erase the allowlist
+ /// the next admin will need (MC-9 in the verification matrix).
///
- private bool TryBuildProxyTargets(string targetsText, out List targets)
+ private static bool TryBuildProxyTargets(string itemName, FQID itemFqid, string targetsText, bool proxyEnabled, out List targets)
{
targets = new List();
var seenOrigins = new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -327,7 +325,7 @@ private bool TryBuildProxyTargets(string targetsText, out List t
}
var canonical = new List();
+ var seenOrigins = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var target in targets)
{
if (target == null)
@@ -109,7 +109,7 @@ private static string BuildProxyTargetsProperty(List t
}
if (ProxyPolicy.TryNormalizeTargetParts(target.Scheme, target.Host, target.Port, out var origin) &&
- !canonical.Contains(origin, StringComparer.OrdinalIgnoreCase))
+ seenOrigins.Add(origin))
{
canonical.Add(origin);
}
diff --git a/src/WebContent/ProxyPolicy.cs b/src/WebContent/ProxyPolicy.cs
index 340c232..7b70b15 100644
--- a/src/WebContent/ProxyPolicy.cs
+++ b/src/WebContent/ProxyPolicy.cs
@@ -35,8 +35,8 @@ internal static string CanonicalOrigin(Uri uri)
{
// Uri.Host carries IPv6 hosts already bracketed ("[::1]"), so it needs no re-wrapping.
var host = uri.Host.ToLowerInvariant();
- var portPart = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
- return $"{uri.Scheme.ToLowerInvariant()}://{host}{portPart}";
+ var port = uri.IsDefaultPort ? 0 : uri.Port;
+ return FormatOrigin(uri.Scheme.ToLowerInvariant(), host, port);
}
///
@@ -60,11 +60,10 @@ internal static bool TryNormalizeTargetParts(string scheme, string host, int por
return false;
}
- var ipv6 = hostPart.StartsWith("[", StringComparison.Ordinal) && hostPart.EndsWith("]", StringComparison.Ordinal);
- if (hostPart.IndexOf(':') >= 0 && !ipv6)
+ if (hostPart.IndexOf(':') >= 0 &&
+ !(hostPart.StartsWith("[", StringComparison.Ordinal) && hostPart.EndsWith("]", StringComparison.Ordinal)))
{
hostPart = $"[{hostPart}]";
- ipv6 = true;
}
if (port < 0 || port > 65535)
@@ -72,12 +71,21 @@ internal static bool TryNormalizeTargetParts(string scheme, string host, int por
return false;
}
- var schemeDefault = schemePart == Uri.UriSchemeHttp ? 80 : 443;
- var portPart = port == 0 || port == schemeDefault ? string.Empty : $":{port}";
- normalized = $"{schemePart}://{hostPart}{portPart}";
+ normalized = FormatOrigin(schemePart, hostPart, port);
return true;
}
+ ///
+ /// The one origin formatter everything else funnels through: port 0 or the scheme
+ /// default renders without an explicit port.
+ ///
+ private static string FormatOrigin(string scheme, string host, int port)
+ {
+ var schemeDefault = scheme == Uri.UriSchemeHttp ? 80 : 443;
+ var portPart = port == 0 || port == schemeDefault ? string.Empty : $":{port}";
+ return $"{scheme}://{host}{portPart}";
+ }
+
///
/// True when the request origin already appears in the allowlisted canonical origins —
/// exact origin match, no wildcards, no path or prefix semantics.
From 794bcf8dfb41203b35a52d583b07594f9330ec02 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Fri, 30 Oct 2026 14:55:50 -0700
Subject: [PATCH 003/187] feat: Event Server proxy fetcher over MIP messaging
with chunked HTTP GET (#53)
plugin.def gains the Service load environment; a Service-targeted BackgroundPlugin answers WebContent.Proxy.FetchRequest, performs the GET under connect/read/size budgets (ProxyHttpFetcher), and streams the outcome back as meta + base64 chunk frames to ProxyFetchClient. Management Client workspace page gains the AC5 demo harness (Test proxy fetch). Pure seams (contract, framer, correlator, transport) dual-registered in both csprojs; 30 new tests pin framing, correlation, transport bounds, payload whitelisting, id-suffix rules, and Service load. Correlator hardening: garbled/hostile frames complete with an error outcome instead of throwing on the receiver thread; no MessageCommunicationManager.Stop (process-wide session shared with other plugins, per MIP sample precedent).
---
docs/verification-matrix.md | 33 ++-
.../EventServerLoadGuardTests.cs | 71 +++++
.../ProxyFetchContractGuardTests.cs | 84 ++++++
.../ProxyFetchCorrelationTests.cs | 253 ++++++++++++++++++
src/WebContent.Tests/ProxyFetchFramerTests.cs | 94 +++++++
.../ProxyFetchTransportTests.cs | 230 ++++++++++++++++
src/WebContent.Tests/WebContent.Tests.csproj | 4 +
src/WebContent/Admin/WorkspaceConfigPage.xaml | 13 +
.../Admin/WorkspaceConfigPage.xaml.cs | 81 ++++++
src/WebContent/ProxyFetchBackgroundPlugin.cs | 229 ++++++++++++++++
src/WebContent/ProxyFetchClient.cs | 157 +++++++++++
src/WebContent/ProxyFetchContract.cs | 169 ++++++++++++
src/WebContent/ProxyFetchCorrelation.cs | 245 +++++++++++++++++
src/WebContent/ProxyFetchFramer.cs | 78 ++++++
src/WebContent/ProxyFetchTransport.cs | 250 +++++++++++++++++
src/WebContent/ProxyPolicy.cs | 10 +
src/WebContent/Strings.Designer.cs | 54 ++++
src/WebContent/Strings.resx | 18 ++
src/WebContent/WebContent.csproj | 6 +
src/WebContent/WebContentPluginDefinition.cs | 38 ++-
src/WebContent/plugin.def | 2 +-
21 files changed, 2116 insertions(+), 3 deletions(-)
create mode 100644 src/WebContent.Tests/EventServerLoadGuardTests.cs
create mode 100644 src/WebContent.Tests/ProxyFetchContractGuardTests.cs
create mode 100644 src/WebContent.Tests/ProxyFetchCorrelationTests.cs
create mode 100644 src/WebContent.Tests/ProxyFetchFramerTests.cs
create mode 100644 src/WebContent.Tests/ProxyFetchTransportTests.cs
create mode 100644 src/WebContent/ProxyFetchBackgroundPlugin.cs
create mode 100644 src/WebContent/ProxyFetchClient.cs
create mode 100644 src/WebContent/ProxyFetchContract.cs
create mode 100644 src/WebContent/ProxyFetchCorrelation.cs
create mode 100644 src/WebContent/ProxyFetchFramer.cs
create mode 100644 src/WebContent/ProxyFetchTransport.cs
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 7e7346f..f1c9283 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -33,6 +33,7 @@ is not evidence the plugin never ran.
| Configuration error visibility | #9 | `WorkspaceDefinitionTests` (`ConfigurationError` classifies malformed JSON, blob-without-items, no-home-address item, nothing configured; malformed JSON reported even when legacy properties would rescue it; `SelectVisible` keeps named-but-broken tabs and still skips unnamed/unpermitted) | See MC-8 below: broken tab appears with a WorkSpaceNotification warning |
| SBOM integrity | #25 | `SbomGenerationGuardTests` (PostBuildEvent keeps a quoted absolute `-bc "$(MSBuildProjectDirectory)"` component path and `-D true`; the original bug let the CWD-relative `-bc .\` point at the build drop, so zero packages were detected) | `.\build.ps1` throws unless `output\WebContent\_manifest\spdx_2.2\manifest.spdx.json` lists referenced packages; CI Build job shows `SBOM OK` and no "no packages detected" annotation |
| Proxy configuration roundtrip | #52 | `ProxyConfigurationTests` (definitions saved before the proxy existed mean proxy-off; toggle+targets survive the serialized roundtrip), `ProxyPolicyTests` (admin entries canonicalize to scheme/host/port origins; garbage rejected; exact-origin matching; duplicates/overlaps collapse), `WorkspacePropertiesTests` (JSON-blob and legacy paths emit canonical `ProxyEnabled`/`ProxyTargets`, hand-crafted target gibberish skipped) | See MC-9 below: admin toggle round-trips to the Smart Client properties page; invalid target save refused with log marker |
+| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types are plugin-namespace `[Serializable]` with public fields — the whitelist case that keeps `MessageCommunicationSettings.config` unnecessary; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; timeout), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged |
## Manual host checks
@@ -96,6 +97,34 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
3. Toggle the proxy off, Save, and check the target list is still present in the stored definition (off-toggle preserves allowlisted targets; MC-9 pin for the wipe bug found in review).
4. With the proxy enabled, enter an invalid target line (`ftp://x`, `not a url`) and Save: refused with a message naming the line; MC log marker: `Rejected save for workspace '' (FQID: ...): invalid proxy target`. Enabled with no targets at all is refused likewise (`proxy enabled but no target allowlisted`).
+### ES-1 Event Server fetcher load and answer (#53)
+
+1. Deploy the new drop per `AGENTS.md`, then restart the XProtect Event Server service (it only
+ loads MIP plugins at service start; plugin.def gained the `Service` load environment).
+2. Expected marker in the Event Server's `Logs\MIP.log` (ProgramData for the Event Server):
+ `Proxy fetcher ready (concurrency=3, request id=WebContent.Proxy.FetchRequest)`. A startup
+ failure logs `Proxy fetcher failed to start: ...` instead.
+3. No `MessageCommunicationSettings.config` whitelist edit is needed: every payload type
+ (`ProxyFetchRequestPayload`, `ProxyFetchResponsePayload`, `ProxyFetchHeader`) lives in the
+ plugin's own namespace, which the messaging layer trusts by default. The escape hatch only
+ becomes necessary if a payload ever references a foreign assembly type — then that type would
+ have to be added to the config file next to the host executable.
+
+### MC-10 Management Client proxy-fetch harness (#53)
+
+1. Restart Management Client with the new drop. Open a Web Content workspace definition — the
+ proxy section now has a "Test proxy fetch" row (URL textbox + button).
+2. Enter a URL the **server** network can reach (e.g. `https://www.example.com/`) and click
+ "Fetch via Event Server". Expected: status line shows 200 with body size and duration within
+ a few seconds.
+3. Failure cases: enter `http://10.255.255.1/` (unreachable) → the result shows `Unreachable`
+ after the ~5 s connect budget, and the Event Server log gains `Proxy fetch failed
+ (code=Unreachable, target=...)`. Enter `not a url` → `BadRequest` refusal logged as
+ `Proxy fetch request ... refused: the url was not an absolute http(s) uri`. Successes are
+ deliberately not logged (fetches become high-frequency once views use the proxy).
+4. If the Event Server has no plugin loaded, the harness shows the not-connected message without
+ sending anything.
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
@@ -110,6 +139,8 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
| Broken workspace config silently hiding its tab again (#9) | `WorkspaceDefinitionTests` (`ConfigurationError` + `SelectVisible` visibility pins), MC-8 |
| Proxy allowlist lost on toggle-off, or proxy silently defaulting on for old definitions (#52) | `ProxyConfigurationTests` (legacy blob = proxy off; roundtrip), MC-9 |
| Inconsistent proxy-target canonicalization between admin and runtime flows (#52) | `ProxyPolicyTests` + `WorkspacePropertiesTests` (shared `ProxyPolicy` canonical form in both save and Build paths) |
+| Fetch message ids renamed to something ending in "Command", or payloads moved to a non-whitelisted type, silently breaking SecurityLevel.Normal clients or deserialization (#53) | `ProxyFetchContractGuardTests` (id-suffix + serializability/whitelist pins), ES-1 |
+| Chunk framer or correlation reassembler losing frames/order so the client sees truncated bodies (#53) | `ProxyFetchFramerTests`, `ProxyFetchCorrelationTests`, `ProxyFetchTransportTests` |
## Scope notes (deliberate)
@@ -118,4 +149,4 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
ever refactored, extract the policy into a pure class first and move MC-4's backoff
assertions into the suite.
- WebView2/Smart Client UI behavior (actual page render, WPF address-bar visuals) cannot be
- unit-tested at the pure seams; MC-1–MC-9 are the release gate for those.
+ unit-tested at the pure seams; MC-1–MC-10 (plus ES-1) are the release gate for those.
diff --git a/src/WebContent.Tests/EventServerLoadGuardTests.cs b/src/WebContent.Tests/EventServerLoadGuardTests.cs
new file mode 100644
index 0000000..66f2eea
--- /dev/null
+++ b/src/WebContent.Tests/EventServerLoadGuardTests.cs
@@ -0,0 +1,71 @@
+using System.IO;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #53: the same assembly loads into the XProtect Event Server to host the proxy-fetch
+ /// background plugin. Loading is governed by plugin.def's ; the fetcher activates
+ /// only for the Service environment; Smart Client view/WebView2 code paths stay in their own
+ /// case so the Event Server never touches them. These source guards pin the load contract;
+ /// the live proof is ES-1 in docs/verification-matrix.md.
+ ///
+ public class EventServerLoadGuardTests
+ {
+ [Fact]
+ public void PluginDefDeclaresTheServiceEnvironmentForEventServerLoading()
+ {
+ var pluginDef = File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "plugin.def"));
+
+ Assert.Matches(@"\s+BackgroundPlugins", definition);
+ Assert.Matches(@"override\s+List\s+PluginDefinedMessageIds", definition);
+ Assert.Matches("FetchRequestId", definition);
+ Assert.Matches("FetchResponseId", definition);
+ }
+
+ [Fact]
+ public void SmartClientViewCodeStaysGuardedToTheSmartClientCase()
+ {
+ var definition = ReadDefinition();
+
+ // ViewItemPlugins population may only happen inside the SmartClient case; the Event
+ // Server path must never reach the WebView2 view stack.
+ var smartClientCase = Regex.Match(
+ definition,
+ @"case EnvironmentType\.SmartClient:(.*?)(?=case EnvironmentType\.Standalone)",
+ RegexOptions.Singleline);
+ Assert.True(smartClientCase.Success);
+ Assert.Matches(@"ViewItemPlugins\.Add\(new WebContentViewItemPlugin\(\)\)", smartClientCase.Groups[1].Value);
+
+ var outsideSmartClientCase = definition.Replace(smartClientCase.Value, string.Empty);
+ Assert.DoesNotMatch(@"ViewItemPlugins\.Add\(new WebContentViewItemPlugin\(\)\)", outsideSmartClientCase);
+ }
+
+ private static string ReadDefinition()
+ {
+ return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "WebContentPluginDefinition.cs"));
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyFetchContractGuardTests.cs b/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
new file mode 100644
index 0000000..88667f5
--- /dev/null
+++ b/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reflection;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #53: the Event Server discards messages whose Data object sits outside the payload
+ /// whitelist - only listed System types plus types from the plug-in's own namespace are
+ /// allowed. These guards pin that every proxy-fetch payload field and every container
+ /// element stays inside that whitelist, and that the proxy message ids are shaped so a
+ /// receiver's default security level (which ignores ids ending in "Command") can accept them.
+ ///
+ public class ProxyFetchContractGuardTests
+ {
+ private static readonly Type[] WhitelistedValueTypePrimitives =
+ {
+ typeof(string), typeof(short), typeof(int), typeof(long),
+ typeof(float), typeof(double), typeof(char), typeof(bool),
+ typeof(DateTime), typeof(TimeSpan), typeof(Guid),
+ };
+
+ [Theory]
+ [InlineData(typeof(ProxyFetchRequestPayload))]
+ [InlineData(typeof(ProxyFetchResponsePayload))]
+ [InlineData(typeof(ProxyFetchHeader))]
+ public void EveryPayloadFieldStaysInsideTheMessageWhitelist(Type payloadType)
+ {
+ foreach (var field in payloadType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
+ {
+ Assert.True(IsWhitelistSafe(field.FieldType, payloadType.Assembly),
+ $"{payloadType.Name}.{field.Name} has non-whitelisted type {field.FieldType.Name}.");
+ }
+ }
+
+ [Theory]
+ [InlineData(typeof(ProxyFetchRequestPayload))]
+ [InlineData(typeof(ProxyFetchResponsePayload))]
+ [InlineData(typeof(ProxyFetchHeader))]
+ public void PayloadsLiveInThePluginNamespaceTheWhitelistGrants(Type payloadType)
+ {
+ // The whitelist escape for plug-in types is namespace-based; a payload living under a
+ // different namespace would need the MessageCommunicationSettings.config hatch.
+ Assert.Equal("WebContent", payloadType.Namespace);
+ }
+
+ [Fact]
+ public void ProxyMessageIdsDoNotEndWithTheIgnoredCommandSuffix()
+ {
+ // Default SecurityLevel.Normal ignores incoming ids ending in "Command"; the proxy
+ // ids must be receivable at Normal.
+ Assert.EndsWith("Request", ProxyMessageIds.FetchRequestId, StringComparison.Ordinal);
+ Assert.EndsWith("Response", ProxyMessageIds.FetchResponseId, StringComparison.Ordinal);
+ }
+
+ private static bool IsWhitelistSafe(Type type, Assembly pluginAssembly)
+ {
+ if (WhitelistedValueTypePrimitives.Contains(type))
+ {
+ return true;
+ }
+
+ // Plugin-namespace types are the sanctioned payload escape hatch.
+ if (type.Assembly == pluginAssembly && type.Namespace == "WebContent")
+ {
+ return true;
+ }
+
+ if (type.IsGenericType)
+ {
+ var definition = type.GetGenericTypeDefinition();
+ if (definition == typeof(List<>) || definition == typeof(Collection<>) || definition == typeof(KeyValuePair<,>))
+ {
+ return type.GetGenericArguments().All(argument => IsWhitelistSafe(argument, pluginAssembly));
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyFetchCorrelationTests.cs b/src/WebContent.Tests/ProxyFetchCorrelationTests.cs
new file mode 100644
index 0000000..5c7fb7b
--- /dev/null
+++ b/src/WebContent.Tests/ProxyFetchCorrelationTests.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #53: the requester side of the proxy-fetch exchange. Response frames arrive over a
+ /// shared message channel tagged only by correlation id; the correlator tracks pending
+ /// requests and decides when the stream is complete (all counted chunks arrived, or a Failure
+ /// frame arrived) — never before, never after foreign ids.
+ ///
+ public class ProxyFetchCorrelationTests
+ {
+ [Fact]
+ public async Task FramedSuccessStreamCompletesWithTheSameResultItStartedAs()
+ {
+ var body = TestBody(1000);
+ var original = new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Content-Type", Value = "text/html" }
+ },
+ Body = body,
+ BodyLength = body.Length,
+ };
+
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-1", TimeSpan.FromSeconds(30));
+
+ foreach (var frame in ProxyFetchFramer.Frame("corr-1", original, chunkSizeBytes: 256))
+ {
+ correlation.Feed(frame);
+ }
+
+ var completed = await completion;
+ Assert.True(completed.Success);
+ Assert.Equal(200, completed.StatusCode);
+ Assert.Equal("OK", completed.StatusText);
+ Assert.Single(completed.Headers);
+ Assert.Equal("Content-Type", completed.Headers[0].Name);
+ Assert.Equal(body, completed.Body);
+ }
+
+ [Fact]
+ public async Task FailureFrameCompletesOnItsOwnWithoutAnyChunks()
+ {
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-2", TimeSpan.FromSeconds(30));
+
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-2",
+ Kind = ProxyFetchFrameKinds.Failure,
+ Sequence = -1,
+ ErrorCode = ProxyFetchErrorCodes.Unreachable,
+ ErrorMessage = "connection refused",
+ });
+
+ var completed = await completion;
+ Assert.False(completed.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Unreachable, completed.ErrorCode);
+ Assert.Equal("connection refused", completed.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task EmptyBodyCompletesOnMetadataFrameAlone()
+ {
+ var result = new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 204,
+ Body = new byte[0],
+ BodyLength = 0,
+ };
+
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-empty", TimeSpan.FromSeconds(30));
+
+ correlation.Feed(ProxyFetchFramer.Frame("corr-empty", result, chunkSizeBytes: 256)[0]);
+
+ var completed = await completion;
+ Assert.True(completed.Success);
+ Assert.Equal(204, completed.StatusCode);
+ Assert.Equal(new byte[0], completed.Body);
+ }
+
+ [Fact]
+ public async Task PartialStreamNeverCompletesAndKeepsTheRequestPending()
+ {
+ var body = TestBody(600);
+ var result = new ProxyFetchResult { Success = true, StatusCode = 200, Body = body, BodyLength = body.Length };
+
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-3", TimeSpan.FromMinutes(5));
+
+ var frames = ProxyFetchFramer.Frame("corr-3", result, chunkSizeBytes: 256);
+ correlation.Feed(frames[0]);
+ correlation.Feed(frames[1]);
+
+ Assert.False(completion.IsCompleted);
+
+ correlation.Feed(frames[2]);
+ correlation.Feed(frames[3]);
+
+ var assembled = await completion;
+ Assert.Equal(body, assembled.Body);
+ }
+
+ [Fact]
+ public void FramesForForeignCorrelationIdsAreIgnoredAndStayIncomplete()
+ {
+ var result = new ProxyFetchResult { Success = true, StatusCode = 200, Body = TestBody(10), BodyLength = 10 };
+
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-mine", TimeSpan.FromMinutes(5));
+
+ foreach (var frame in ProxyFetchFramer.Frame("corr-other", result, chunkSizeBytes: 256))
+ {
+ correlation.Feed(frame);
+ }
+
+ Assert.False(completion.IsCompleted);
+ }
+
+ [Fact]
+ public async Task ExpiredDeadlineCompletesTheTaskWithALocalTimeoutResult()
+ {
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-4", TimeSpan.FromMilliseconds(50));
+
+ await Task.Delay(400);
+
+ Assert.True(completion.IsCompleted);
+ Assert.False(completion.Result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Timeout, completion.Result.ErrorCode);
+ }
+
+ [Fact]
+ public async Task ChunkBytesOverrunningTheDeclaredBodyCompleteWithErrorNotThrow()
+ {
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-over", TimeSpan.FromSeconds(30));
+
+ // Meta declares 10 bytes; the chunk frame carries 20. The receiver thread must not
+ // take an exception from BlockCopy — the exchange completes with an error outcome.
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-over",
+ Kind = ProxyFetchFrameKinds.Response,
+ Sequence = -1,
+ StatusCode = 200,
+ BodyLength = 10,
+ ChunkCount = 1,
+ });
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-over",
+ Kind = ProxyFetchFrameKinds.Chunk,
+ Sequence = 0,
+ IsLastChunk = true,
+ ContentBase64 = Convert.ToBase64String(TestBody(20)),
+ });
+
+ var completed = await completion;
+ Assert.False(completed.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Error, completed.ErrorCode);
+ }
+
+ [Fact]
+ public async Task ChunkBytesUnderDeclaringTheBodyCompleteWithErrorNotTruncatedSuccess()
+ {
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-under", TimeSpan.FromSeconds(30));
+
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-under",
+ Kind = ProxyFetchFrameKinds.Response,
+ Sequence = -1,
+ StatusCode = 200,
+ BodyLength = 100,
+ ChunkCount = 1,
+ });
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-under",
+ Kind = ProxyFetchFrameKinds.Chunk,
+ Sequence = 0,
+ IsLastChunk = true,
+ ContentBase64 = Convert.ToBase64String(TestBody(10)),
+ });
+
+ var completed = await completion;
+ Assert.False(completed.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Error, completed.ErrorCode);
+ }
+
+ [Fact]
+ public async Task UnusableBodyDeclarationInMetaFrameCompletesWithErrorWithoutThrowing()
+ {
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-hostile", TimeSpan.FromSeconds(30));
+
+ // Negative and over-cap declarations would blow up the reassembly allocation; the
+ // guard turns them into a normal error outcome.
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-hostile",
+ Kind = ProxyFetchFrameKinds.Response,
+ Sequence = -1,
+ StatusCode = 200,
+ BodyLength = -5,
+ ChunkCount = 1,
+ });
+
+ var completed = await completion;
+ Assert.False(completed.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Error, completed.ErrorCode);
+
+ var second = correlation.Begin("corr-hostile-2", TimeSpan.FromSeconds(30));
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-hostile-2",
+ Kind = ProxyFetchFrameKinds.Response,
+ Sequence = -1,
+ StatusCode = 200,
+ BodyLength = ProxyFetchLimits.DefaultMaxResponseBytes + 1L,
+ ChunkCount = 0,
+ });
+
+ var completed2 = await second;
+ Assert.False(completed2.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Error, completed2.ErrorCode);
+ }
+
+ private static byte[] TestBody(int length)
+ {
+ var body = new byte[length];
+ for (var i = 0; i < length; i++)
+ {
+ body[i] = (byte)(i % 251);
+ }
+
+ return body;
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyFetchFramerTests.cs b/src/WebContent.Tests/ProxyFetchFramerTests.cs
new file mode 100644
index 0000000..7ad1a22
--- /dev/null
+++ b/src/WebContent.Tests/ProxyFetchFramerTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #53: the Event Server fetcher answers a proxy-fetch request by framing the HTTP
+ /// outcome as a stream of plugin-namespaced message payloads: one metadata frame carrying the
+ /// status line and response headers, then base64 body chunks. The framer decides chunk
+ /// boundaries, sequence order, and the chunk-count the requester needs to know it is done.
+ ///
+ public class ProxyFetchFramerTests
+ {
+ [Fact]
+ public void SuccessFramesStartWithMetadataAndCoverTheWholeBodyInChunks()
+ {
+ var body = TestBody(1000);
+ var result = new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Content-Type", Value = "text/html" }
+ },
+ Body = body,
+ BodyLength = 1000,
+ };
+
+ var frames = ProxyFetchFramer.Frame("corr-1", result, chunkSizeBytes: 256);
+
+ // 1 metadata frame + 4 chunk frames (1000 bytes at 256-byte chunks).
+ Assert.Equal(5, frames.Count);
+
+ var meta = frames[0];
+ Assert.Equal("corr-1", meta.CorrelationId);
+ Assert.Equal(ProxyFetchFrameKinds.Response, meta.Kind);
+ Assert.Equal(200, meta.StatusCode);
+ Assert.Equal("OK", meta.StatusText);
+ Assert.Equal(1000, meta.BodyLength);
+ Assert.Equal(4, meta.ChunkCount);
+ Assert.Single(meta.Headers);
+ Assert.Equal("Content-Type", meta.Headers[0].Name);
+
+ // Chunk frames decode back onto the body, in order, last one flagged.
+ var rebuilt = new byte[1000];
+ for (var index = 0; index < 4; index++)
+ {
+ var chunk = frames[index + 1];
+ Assert.Equal(ProxyFetchFrameKinds.Chunk, chunk.Kind);
+ Assert.Equal(index, chunk.Sequence);
+ Assert.Equal(index == 3, chunk.IsLastChunk);
+ var bytes = Convert.FromBase64String(chunk.ContentBase64);
+ Buffer.BlockCopy(bytes, 0, rebuilt, index * 256, bytes.Length);
+ }
+
+ Assert.Equal(body, rebuilt);
+ }
+
+ [Fact]
+ public void FailureOutcomeFramesAsOneTerminalFrameWithNoChunks()
+ {
+ var result = new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Unreachable,
+ ErrorMessage = "No connection refused at 10.0.0.5:8081",
+ };
+
+ var frames = ProxyFetchFramer.Frame("corr-2", result, chunkSizeBytes: 64);
+
+ var failure = Assert.Single(frames);
+ Assert.Equal(ProxyFetchFrameKinds.Failure, failure.Kind);
+ Assert.Equal("corr-2", failure.CorrelationId);
+ Assert.Equal(ProxyFetchErrorCodes.Unreachable, failure.ErrorCode);
+ Assert.Equal("No connection refused at 10.0.0.5:8081", failure.ErrorMessage);
+ Assert.Null(failure.ContentBase64);
+ Assert.Equal(0, failure.ChunkCount);
+ }
+
+ private static byte[] TestBody(int length)
+ {
+ var body = new byte[length];
+ for (var i = 0; i < length; i++)
+ {
+ body[i] = (byte)(i % 251);
+ }
+
+ return body;
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyFetchTransportTests.cs b/src/WebContent.Tests/ProxyFetchTransportTests.cs
new file mode 100644
index 0000000..9a21b3b
--- /dev/null
+++ b/src/WebContent.Tests/ProxyFetchTransportTests.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #53: the Event Server fetcher performs the HTTP(S) GET from the server network and
+ /// enforces connect timeout, read timeout, and a maximum response size; the status line and
+ /// response headers come back with the body. These pins run the real HttpWebRequest transport
+ /// against a local test page.
+ ///
+ public class ProxyFetchTransportTests : IDisposable
+ {
+ private readonly TestHttpServer _server = new TestHttpServer();
+
+ [Fact]
+ public async Task FetchingTheTestPageCapturesStatusHeadersAndBody()
+ {
+ _server.Handler = async context =>
+ {
+ context.Response.StatusCode = 200;
+ context.Response.ContentType = "text/html";
+ context.Response.Headers["X-Probe"] = "present";
+ var page = Encoding.UTF8.GetBytes("proxy test page");
+ await context.Response.OutputStream.WriteAsync(page, 0, page.Length);
+ context.Response.Close();
+ };
+
+ var fetcher = new ProxyHttpFetcher();
+ var result = await fetcher.FetchAsync(new Uri(_server.UrlFor("/index.html")), ProxyFetchLimits.Default());
+
+ Assert.True(result.Success);
+ Assert.Equal(200, result.StatusCode);
+ Assert.Equal(Encoding.UTF8.GetBytes("proxy test page"), result.Body);
+ Assert.Equal("proxy test page".Length, result.BodyLength);
+
+ var headers = result.Headers ?? new List();
+ Assert.Contains(headers, h => string.Equals(h.Name, "X-Probe", StringComparison.OrdinalIgnoreCase) && h.Value == "present");
+ Assert.Contains(headers, h => string.Equals(h.Name, "Content-Type", StringComparison.OrdinalIgnoreCase) && h.Value.StartsWith("text/html", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task BodyOverTheMaximumResponseSizeIsRefusedWithTooLarge()
+ {
+ _server.Handler = async context =>
+ {
+ var page = new byte[5000];
+ await context.Response.OutputStream.WriteAsync(page, 0, page.Length);
+ context.Response.Close();
+ };
+
+ var limits = ProxyFetchLimits.Default();
+ limits.MaxResponseBytes = 1000;
+
+ var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/large")), limits);
+
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.TooLarge, result.ErrorCode);
+ }
+
+ [Fact]
+ public async Task BodyStallingPastTheReadTimeoutIsRefusedWithTimeout()
+ {
+ _server.Handler = async context =>
+ {
+ context.Response.SendChunked = true;
+ var first = new byte[] { 1 };
+ await context.Response.OutputStream.WriteAsync(first, 0, 1);
+
+ // Stall well past the read budget, then try to keep the stream alive.
+ await Task.Delay(400);
+ var second = new byte[] { 2 };
+ await context.Response.OutputStream.WriteAsync(second, 0, 1);
+ context.Response.Close();
+ };
+
+ var limits = ProxyFetchLimits.Default();
+ limits.ReadTimeoutMs = 100;
+
+ var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/stalling")), limits);
+
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Timeout, result.ErrorCode);
+ }
+
+ [Fact]
+ public async Task HeadersStallingPastTheConnectBudgetIsRefusedWithTimeout()
+ {
+ _server.Handler = async context =>
+ {
+ // Never send the response - headers alone must fail inside the connect budget.
+ await Task.Delay(4000);
+ context.Response.Close();
+ };
+
+ var limits = ProxyFetchLimits.Default();
+ limits.ConnectTimeoutMs = 200;
+
+ var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/silent")), limits);
+
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Timeout, result.ErrorCode);
+ }
+
+ [Fact]
+ public async Task AnUnreachableTargetReportsUnreachableNotSuccess()
+ {
+ var unusedPort = FreePort();
+ var result = await new ProxyHttpFetcher().FetchAsync(new Uri($"http://127.0.0.1:{unusedPort}/nope"), ProxyFetchLimits.Default());
+
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Unreachable, result.ErrorCode);
+ }
+
+ [Fact]
+ public async Task AnHttpErrorStatusIsStillPassedThroughAsAStatusLine()
+ {
+ _server.Handler = async context =>
+ {
+ context.Response.StatusCode = 404;
+ var page = Encoding.UTF8.GetBytes("not found page");
+ await context.Response.OutputStream.WriteAsync(page, 0, page.Length);
+ context.Response.Close();
+ };
+
+ var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/missing")), ProxyFetchLimits.Default());
+
+ // The proxy relays whatever the target answered; a 404 page is still a page.
+ Assert.True(result.Success);
+ Assert.Equal(404, result.StatusCode);
+ Assert.Equal(Encoding.UTF8.GetBytes("not found page"), result.Body);
+ }
+
+ [Theory]
+ [InlineData("ftp://127.0.0.1/")]
+ [InlineData("file:///C:/windows/win.ini")]
+ public async Task NonHttpSchemeIsRefusedWithoutTouchingTheNetwork(string target)
+ {
+ var result = await new ProxyHttpFetcher().FetchAsync(new Uri(target), ProxyFetchLimits.Default());
+
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.BadRequest, result.ErrorCode);
+ }
+
+ public void Dispose()
+ {
+ _server.Dispose();
+ }
+
+ private static int FreePort()
+ {
+ var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ return port;
+ }
+
+ /// Loopback HttpListener standing in for "a test page served on the server network".
+ private sealed class TestHttpServer : IDisposable
+ {
+ private readonly HttpListener _listener;
+ private readonly string _prefix;
+
+ internal TestHttpServer()
+ {
+ var port = FreePort();
+ _prefix = $"http://127.0.0.1:{port}/";
+ _listener = new HttpListener();
+ _listener.Prefixes.Add(_prefix);
+ _listener.Start();
+
+ Task.Run(async () =>
+ {
+ while (_listener.IsListening)
+ {
+ HttpListenerContext context;
+ try
+ {
+ context = await _listener.GetContextAsync();
+ }
+ catch (Exception)
+ {
+ return;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Handler(context);
+ }
+ catch
+ {
+ // The client losing interest is not a test failure.
+ }
+ });
+ }
+ });
+ }
+
+ internal Func Handler = context =>
+ {
+ context.Response.StatusCode = 500;
+ context.Response.Close();
+ return Task.CompletedTask;
+ };
+
+ internal string UrlFor(string path) => _prefix + path.TrimStart('/');
+
+ public void Dispose()
+ {
+ try
+ {
+ _listener.Stop();
+ _listener.Close();
+ }
+ catch
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 5105828..204df7f 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -30,6 +30,10 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/WebContent/Admin/WorkspaceConfigPage.xaml b/src/WebContent/Admin/WorkspaceConfigPage.xaml
index 982ac82..9170441 100644
--- a/src/WebContent/Admin/WorkspaceConfigPage.xaml
+++ b/src/WebContent/Admin/WorkspaceConfigPage.xaml
@@ -99,6 +99,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs b/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
index 585aa8c..70fe19d 100644
--- a/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
+++ b/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
@@ -15,6 +16,7 @@ namespace WebContent.Admin
public partial class WorkspaceConfigPage : UserControl
{
private bool _syncingZoom;
+ private bool _proxyTestRunning;
internal event EventHandler ConfigurationChanged;
@@ -34,6 +36,8 @@ public WorkspaceConfigPage()
chkProxyEnabled.Content = Strings.ProxyEnabledLabel;
lblProxyTargets.Text = Strings.ProxyTargetsLabel + ":";
lblProxyTargetsHint.Text = Strings.ProxyTargetsHint;
+ lblProxyTestUrl.Text = Strings.ProxyTestFetchLabel + ":";
+ lblProxyTestHint.Text = Strings.ProxyTestFetchHint;
}
public string WorkspaceName
@@ -158,6 +162,9 @@ public void ClearUserControl()
chkEnableDevTools.IsChecked = false;
chkProxyEnabled.IsChecked = false;
txtProxyTargets.Text = string.Empty;
+ txtProxyTestUrl.Text = string.Empty;
+ lblProxyTestResult.Text = string.Empty;
+ lblProxyTestResult.Visibility = Visibility.Collapsed;
configError.Visibility = Visibility.Collapsed;
chkHideAddress.IsEnabled = true;
}
@@ -221,5 +228,79 @@ private void OnProxyTargetsTextChanged(object sender, TextChangedEventArgs e)
{
ConfigurationChanged?.Invoke(this, EventArgs.Empty);
}
+
+ ///
+ /// Ticket #53 demo harness: sends a proxy-fetch request from the Management Client over
+ /// classic MIP messaging (ProxyFetchClient.Active, provisioned during Init) and shows the
+ /// status line, body size, and duration that came back from the Event Server fetcher.
+ /// The await continues off the UI thread, so result display re-dispatches onto the
+ /// page's dispatcher.
+ ///
+ private async void OnProxyTestFetch(object sender, RoutedEventArgs e)
+ {
+ if (_proxyTestRunning)
+ {
+ return;
+ }
+
+ _proxyTestRunning = true;
+ btnProxyTestFetch.IsEnabled = false;
+ lblProxyTestResult.Visibility = Visibility.Visible;
+ lblProxyTestResult.Text = Strings.ProxyTestFetchRunning;
+
+ try
+ {
+ var client = ProxyFetchClient.Active;
+ if (client == null || !client.IsReady)
+ {
+ lblProxyTestResult.Text = Strings.ProxyTestFetchNotConnected;
+ return;
+ }
+
+ var url = (txtProxyTestUrl.Text ?? string.Empty).Trim();
+ var stopwatch = Stopwatch.StartNew();
+ var result = await client.FetchAsync(url).ConfigureAwait(false);
+ stopwatch.Stop();
+
+ string outcome;
+ if (result.Success)
+ {
+ outcome = string.Format(
+ CultureInfo.InvariantCulture,
+ Strings.ProxyTestFetchSuccessFormat,
+ result.StatusCode,
+ result.Body == null ? 0L : (long)result.Body.LongLength,
+ stopwatch.Elapsed.TotalSeconds);
+ }
+ else
+ {
+ outcome = string.Format(
+ CultureInfo.InvariantCulture,
+ Strings.ProxyTestFetchFailureFormat,
+ result.ErrorCode,
+ stopwatch.Elapsed.TotalSeconds,
+ result.ErrorMessage);
+ }
+
+ Dispatcher.Invoke(() => lblProxyTestResult.Text = outcome);
+ }
+ catch (Exception exception)
+ {
+ Dispatcher.Invoke(() => lblProxyTestResult.Text = string.Format(
+ CultureInfo.InvariantCulture,
+ Strings.ProxyTestFetchFailureFormat,
+ ProxyFetchErrorCodes.Error,
+ 0d,
+ exception.Message));
+ }
+ finally
+ {
+ _proxyTestRunning = false;
+ if (btnProxyTestFetch != null)
+ {
+ btnProxyTestFetch.IsEnabled = true;
+ }
+ }
+ }
}
}
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
new file mode 100644
index 0000000..42ff40b
--- /dev/null
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using VideoOS.Platform;
+using VideoOS.Platform.Background;
+using VideoOS.Platform.Messaging;
+
+namespace WebContent
+{
+ ///
+ /// Ticket #53: the server-side half of the proxy-fetch contract. The same WebContent.dll
+ /// loads into the XProtect Event Server (plugin.def load env "Service"), where this
+ /// BackgroundPlugin answers WebContent.Proxy.FetchRequest messages: it performs the HTTP(S)
+ /// GET from the server network under the ProxyHttpFetcher budgets (connect timeout, read
+ /// timeout, maximum response size) and streams the outcome back to the requester as framed,
+ /// plugin-namespaced response messages (metadata frame with status line and content headers,
+ /// then base64 body chunks).
+ ///
+ /// Failure classes (unreachable target, size cap exceeded, timeout) are logged to the Event
+ /// Server MIP log with actionable context; successful fetches are silent because proxied page
+ /// views make that stream high-frequency.
+ ///
+ internal sealed class ProxyFetchBackgroundPlugin : BackgroundPlugin
+ {
+ private static readonly Guid PluginId = new Guid("ced898c8-25f6-406e-b4fa-aecae669fbb0");
+
+ private const int MaxConcurrentFetches = 3;
+ private static readonly TimeSpan FetchSlotWait = TimeSpan.FromSeconds(10);
+
+ private ServerId _serverId;
+ private MessageCommunication _messageCommunication;
+ private object _requestFilterRegistration;
+ private SemaphoreSlim _fetchSlots;
+
+ public override Guid Id => PluginId;
+
+ public override string Name => "Web Content proxy fetcher";
+
+ public override List TargetEnvironments { get; } = new List { EnvironmentType.Service };
+
+ public override void Init()
+ {
+ _fetchSlots = new SemaphoreSlim(MaxConcurrentFetches, MaxConcurrentFetches);
+
+ try
+ {
+ _serverId = EnvironmentManager.Instance.MasterSite.ServerId;
+
+ // Per the MIP messaging docs the Event Server plug-in may initialize the message
+ // communication client directly in Init().
+ MessageCommunicationManager.Start(_serverId);
+ _messageCommunication = MessageCommunicationManager.Get(_serverId);
+ _requestFilterRegistration = _messageCommunication.RegisterCommunicationFilter(
+ OnProxyFetchRequest,
+ new CommunicationIdFilter(ProxyMessageIds.FetchRequestId));
+
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Proxy fetcher ready (concurrency={MaxConcurrentFetches}, request id={ProxyMessageIds.FetchRequestId}).");
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Proxy fetcher failed to start: {exception.Message}");
+ }
+ }
+
+ public override void Close()
+ {
+ if (_messageCommunication != null && _requestFilterRegistration != null)
+ {
+ try
+ {
+ _messageCommunication.UnRegisterCommunicationFilter(_requestFilterRegistration);
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy fetch filter detach failed: {exception.Message}");
+ }
+ }
+
+ _requestFilterRegistration = null;
+ _messageCommunication = null;
+
+ // Deliberately no MessageCommunicationManager.Stop: the messaging session is
+ // process-wide and may be shared by other plugins (no MIP sample ever stops it).
+ _serverId = null;
+
+ var slots = _fetchSlots;
+ _fetchSlots = null;
+ slots?.Dispose();
+
+ EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), "Proxy fetcher stopped.");
+ }
+
+ ///
+ /// Message-receiver thread: keep it non-blocking. The fetch runs on the thread pool and
+ /// the response stream is pushed back to the requester's endpoint FQID.
+ ///
+ private object OnProxyFetchRequest(Message message, FQID destination, FQID source)
+ {
+ var payload = message?.Data as ProxyFetchRequestPayload;
+ if (payload == null || string.IsNullOrEmpty(payload.CorrelationId) || string.IsNullOrEmpty(payload.Url))
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Proxy fetch request carried no usable payload; refused.");
+ return null;
+ }
+
+ Uri target;
+ if (!Uri.TryCreate(payload.Url, UriKind.Absolute, out target) || !ProxyPolicy.IsHttpUri(target))
+ {
+ LogRefused(payload, "the url was not an absolute http(s) uri");
+ TransmitFailure(payload.CorrelationId, source, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri.");
+ return null;
+ }
+
+ var communication = _messageCommunication;
+ if (communication == null)
+ {
+ LogRefused(payload, "the fetcher is not connected to the Event Server");
+ TransmitFailure(payload.CorrelationId, source, ProxyFetchErrorCodes.Error, "The proxy fetcher is not ready.");
+ return null;
+ }
+
+ var slots = _fetchSlots;
+ if (slots == null)
+ {
+ return null;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ if (!await slots.WaitAsync(FetchSlotWait))
+ {
+ LogRefused(payload, "the fetcher queue was saturated");
+ TransmitFailure(payload.CorrelationId, source, ProxyFetchErrorCodes.Error, "The proxy fetcher is saturated; try again later.");
+ return;
+ }
+
+ try
+ {
+ await ServeFetchAsync(communication, payload, target, source);
+ }
+ finally
+ {
+ slots.Release();
+ }
+ });
+
+ return null;
+ }
+
+ private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetchRequestPayload payload, Uri target, FQID requesterFqid)
+ {
+ try
+ {
+ var limits = ProxyFetchLimits.Default();
+ var result = await new ProxyHttpFetcher().FetchAsync(target, limits);
+
+ if (!result.Success)
+ {
+ // Ticket acceptance: failure cases produce clear log entries in the Event
+ // Server log. Successes stay quiet: proxied page views make fetches frequent.
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Proxy fetch failed (code={result.ErrorCode}, target={ProxyPolicy.CanonicalOrigin(target)}): {result.ErrorMessage}");
+ }
+
+ foreach (var frame in ProxyFetchFramer.Frame(payload.CorrelationId, result, limits.ChunkSizeBytes))
+ {
+ communication.TransmitMessage(new Message(ProxyMessageIds.FetchResponseId, frame), requesterFqid, null, null);
+ }
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Proxy fetch aborted (target={(target == null ? "unknown" : ProxyPolicy.CanonicalOrigin(target))}): {exception.Message}");
+
+ try
+ {
+ TransmitFailure(payload.CorrelationId, requesterFqid, ProxyFetchErrorCodes.Error, exception.Message);
+ }
+ catch (Exception transmitException)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy failure frame not sent: {transmitException.Message}");
+ }
+ }
+ }
+
+ private void TransmitFailure(string correlationId, FQID requester, string errorCode, string message)
+ {
+ var communication = _messageCommunication;
+ if (communication == null || requester == null)
+ {
+ return;
+ }
+
+ communication.TransmitMessage(
+ new Message(
+ ProxyMessageIds.FetchResponseId,
+ new ProxyFetchResponsePayload
+ {
+ CorrelationId = correlationId,
+ Kind = ProxyFetchFrameKinds.Failure,
+ Sequence = -1,
+ ErrorCode = errorCode,
+ ErrorMessage = message,
+ }),
+ requester,
+ null,
+ null);
+ }
+
+ private static void LogRefused(ProxyFetchRequestPayload payload, string reason)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Proxy fetch request {(payload == null ? "" : payload.CorrelationId)} refused: {reason}.");
+ }
+ }
+}
diff --git a/src/WebContent/ProxyFetchClient.cs b/src/WebContent/ProxyFetchClient.cs
new file mode 100644
index 0000000..dfa9daa
--- /dev/null
+++ b/src/WebContent/ProxyFetchClient.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Threading.Tasks;
+using VideoOS.Platform;
+using VideoOS.Platform.Messaging;
+
+namespace WebContent
+{
+ ///
+ /// Requester side of the proxy-fetch contract (#53): sends WebContent.Proxy.FetchRequest over
+ /// classic MIP messaging and hands back the reassembled outcome from the framed response
+ /// stream. This is the client the ticket's "Management Client-driven harness" drives, and the
+ /// seam the Smart Client view consumes in #54.
+ ///
+ /// The response receiver feeds the correlator; correlation identity (Guid) is local, so no
+ /// other host's in-flight exchanges leak into this requester's results.
+ ///
+ internal sealed class ProxyFetchClient
+ {
+ ///
+ /// Wired up during PluginDefinition.Init in the Administration environment; null until an
+ /// environment provisions it.
+ ///
+ internal static ProxyFetchClient Active;
+
+ private static readonly TimeSpan DefaultDeadline = TimeSpan.FromSeconds(30);
+
+ private ServerId _serverId;
+ private MessageCommunication _communication;
+ private object _responseFilterRegistration;
+ private ProxyFetchCorrelation _correlation;
+
+ internal bool IsReady => _communication != null;
+
+ ///
+ /// Start MIP message communication for the master site and subscribe to proxy-fetch
+ /// responses. Failures are logged, not thrown: an unready harness must not fail the host.
+ ///
+ internal void Setup(ServerId masterSite)
+ {
+ if (masterSite == null)
+ {
+ EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Proxy fetch harness skipped: no master site is connected.");
+ return;
+ }
+
+ try
+ {
+ MessageCommunicationManager.Start(masterSite);
+ _serverId = masterSite;
+ _communication = MessageCommunicationManager.Get(masterSite);
+ _correlation = new ProxyFetchCorrelation();
+ _responseFilterRegistration = _communication.RegisterCommunicationFilter(
+ OnProxyFetchResponse,
+ new CommunicationIdFilter(ProxyMessageIds.FetchResponseId));
+
+ EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Proxy fetch harness ready.");
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch harness failed to start: {exception.Message}");
+ _communication = null;
+ _correlation = null;
+ }
+ }
+
+ internal void Close()
+ {
+ var communication = _communication;
+ if (communication != null && _responseFilterRegistration != null)
+ {
+ try
+ {
+ communication.UnRegisterCommunicationFilter(_responseFilterRegistration);
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch response filter detach failed: {exception.Message}");
+ }
+ }
+
+ _responseFilterRegistration = null;
+ _communication = null;
+ _correlation = null;
+
+ // Deliberately no MessageCommunicationManager.Stop: the messaging session is
+ // process-wide and may be shared by other plugins (no MIP sample ever stops it).
+ _serverId = null;
+ }
+
+ ///
+ /// Send one proxy-fetch request and return the task completed with the reassembled
+ /// outcome. The deadline bounds the whole exchange, not each frame.
+ ///
+ internal Task FetchAsync(string url, TimeSpan? deadline = null)
+ {
+ var communication = _communication;
+ if (communication == null)
+ {
+ return Task.FromResult(Failure(ProxyFetchErrorCodes.Error, "The proxy fetch harness is not connected to the Event Server."));
+ }
+
+ Uri target;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out target) || !ProxyPolicy.IsHttpUri(target))
+ {
+ return Task.FromResult(Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri."));
+ }
+
+ var correlationId = Guid.NewGuid().ToString("N");
+ var completion = _correlation.Begin(correlationId, deadline ?? DefaultDeadline);
+
+ try
+ {
+ communication.TransmitMessage(
+ new Message(
+ ProxyMessageIds.FetchRequestId,
+ new ProxyFetchRequestPayload
+ {
+ CorrelationId = correlationId,
+ Url = target.AbsoluteUri,
+ }),
+ null,
+ null,
+ null);
+ }
+ catch (Exception exception)
+ {
+ // MIP messaging is down: the correlator already owns the task, so let its
+ // deadline/abandon path complete it, and label the outcome locally.
+ _correlation.Abandon(correlationId);
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch request not sent: {exception.Message}");
+ }
+
+ return completion;
+ }
+
+ private object OnProxyFetchResponse(Message message, FQID destination, FQID source)
+ {
+ var frame = message?.Data as ProxyFetchResponsePayload;
+ if (frame != null)
+ {
+ _correlation?.Feed(frame);
+ }
+
+ return null;
+ }
+
+ private static ProxyFetchResult Failure(string errorCode, string message)
+ {
+ return new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = errorCode,
+ ErrorMessage = message,
+ };
+ }
+ }
+}
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
new file mode 100644
index 0000000..d91663e
--- /dev/null
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+
+namespace WebContent
+{
+ ///
+ /// Ticket #53: classic MIP message ids for the proxy-fetch request/response pair. The MIP
+ /// messaging contract (MIP message communication, routed by the Event Server) requires one id
+ /// for the request and another for the response, transmitted as two operations. These ids are
+ /// also registered via PluginDefinition.PluginDefinedMessageIds.
+ ///
+ /// Payload whitelist: the Event Server discards messages whose Data object is outside the
+ /// allowed type set, but "types from plug-in defined namespace are allowed", so every payload
+ /// below lives in this plugin namespace and only references whitelist-safe primitives,
+ /// List<T>, and plugin-namespace types. No MessageCommunicationSettings.config escape
+ /// hatch is needed as long as that rule holds (docs/verification-matrix.md, ES rows).
+ ///
+ internal static class ProxyMessageIds
+ {
+ internal const string FetchRequestId = "WebContent.Proxy.FetchRequest";
+ internal const string FetchResponseId = "WebContent.Proxy.FetchResponse";
+ }
+
+ ///
+ /// Frame kinds within the response stream. The requester treats these as the terminal states
+ /// of a proxy-fetch exchange: Response + Chunk* on success, a lone Failure otherwise.
+ ///
+ internal static class ProxyFetchFrameKinds
+ {
+ /// First frame: status line, response headers, body length, and chunk count.
+ internal const string Response = "Response";
+
+ /// One base64 body slice, sent in sequence after the metadata frame.
+ internal const string Chunk = "Chunk";
+
+ /// Terminal failure frame: no chunks follow.
+ internal const string Failure = "Failure";
+ }
+
+ ///
+ /// Machine-readable failure codes carried in Failure frames and in unsuccessful results.
+ ///
+ internal static class ProxyFetchErrorCodes
+ {
+ /// The request payload was missing, the url blank, or not an http(s) uri.
+ internal const string BadRequest = "BadRequest";
+
+ /// DNS, connect, or TLS failure: the target could not be reached.
+ internal const string Unreachable = "Unreachable";
+
+ /// Connect or read timeout per the fetcher's enforced limits.
+ internal const string Timeout = "Timeout";
+
+ /// The response body exceeded the configured maximum response size.
+ internal const string TooLarge = "TooLarge";
+
+ /// Any other transport-level failure.
+ internal const string Error = "Error";
+ }
+
+ ///
+ /// One response header from the fetched target, as the fetcher's HTTP layer captured it.
+ /// Plugin-namespace payload type, so the Event Server whitelist lets it through. Mirrors the
+ /// Chat sample's ChatData wire shape: public [Serializable] class with public fields.
+ ///
+ [Serializable]
+ public class ProxyFetchHeader
+ {
+ public string Name;
+ public string Value;
+ }
+
+ ///
+ /// Request payload: one correlation id per request so the requester can match the response
+ /// frames that come back over the shared message channel.
+ ///
+ [Serializable]
+ public class ProxyFetchRequestPayload
+ {
+ public string CorrelationId;
+ public string Url;
+ }
+
+ ///
+ /// Response payload for every frame kind (Response / Chunk / Failure). Fields irrelevant to a
+ /// kind stay null/default. Plugin-namespace type built only from whitelist-safe primitives and
+ /// List<ProxyFetchHeader>.
+ ///
+ [Serializable]
+ public class ProxyFetchResponsePayload
+ {
+ public string CorrelationId;
+ public string Kind;
+
+ /// 0-based chunk order; -1 on metadata and failure frames.
+ public int Sequence;
+
+ /// True on the final chunk frame so a consumer can notice the stream ended.
+ public bool IsLastChunk;
+
+ // Metadata-frame fields.
+ public int StatusCode;
+
+ /// Human-readable status text (the status code name; the wire reason phrase is
+ /// not exposed by HttpWebResponse). Display metadata only.
+ public string StatusText;
+ public List Headers;
+
+ /// Actual fetched body size in bytes (metadata frame).
+ public long BodyLength;
+
+ /// How many chunk frames follow the metadata frame (metadata frame).
+ public int ChunkCount;
+
+ // Chunk-frame fields.
+ public string ContentBase64;
+
+ // Failure-frame fields.
+ public string ErrorCode;
+ public string ErrorMessage;
+ }
+
+ ///
+ /// The fetcher's own outcome model, shared by the server-side fetcher, the framing seam, and
+ /// the requester-side reassembly. Not a message payload: the framer serializes it into frames,
+ /// the correlator deserializes frames back.
+ ///
+ internal class ProxyFetchResult
+ {
+ internal bool Success;
+
+ /// Target HTTP status code; 0 when the transport never produced a response.
+ internal int StatusCode;
+
+ /// Human-readable status text (status code name), carried through the meta frame.
+ internal string StatusText;
+ internal List Headers = new List();
+ internal long BodyLength;
+
+ /// The fetched bytes; null while not yet assembled, empty-array when body was empty.
+ internal byte[] Body;
+
+ // Failure fields.
+ internal string ErrorCode;
+ internal string ErrorMessage;
+ }
+
+ ///
+ /// Bounds the fetcher enforces per request (ticket #53 acceptance: connect timeout, read
+ /// timeout, maximum response size).
+ ///
+ internal class ProxyFetchLimits
+ {
+ internal const int DefaultConnectTimeoutMs = 5000;
+ internal const int DefaultReadTimeoutMs = 15000;
+ internal const int DefaultMaxResponseBytes = 5 * 1024 * 1024;
+ internal const int DefaultChunkSizeBytes = 64 * 1024;
+
+ internal int ConnectTimeoutMs = DefaultConnectTimeoutMs;
+ internal int ReadTimeoutMs = DefaultReadTimeoutMs;
+ internal int MaxResponseBytes = DefaultMaxResponseBytes;
+ internal int ChunkSizeBytes = DefaultChunkSizeBytes;
+
+ internal static ProxyFetchLimits Default()
+ {
+ return new ProxyFetchLimits();
+ }
+ }
+}
diff --git a/src/WebContent/ProxyFetchCorrelation.cs b/src/WebContent/ProxyFetchCorrelation.cs
new file mode 100644
index 0000000..170e891
--- /dev/null
+++ b/src/WebContent/ProxyFetchCorrelation.cs
@@ -0,0 +1,245 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace WebContent
+{
+ ///
+ /// Requester-side protocol state for the proxy-fetch response stream (#53). Response frames
+ /// arrive over the shared MIP message channel; only the correlation id tells the requester
+ /// which request they belong to. The correlator accumulates pending exchanges and completes
+ /// one exactly when the protocol says the stream ended — every counted chunk arrived, or a
+ /// single Failure frame arrived — and enforces the requester's response deadline so stalled
+ /// exchanges are bounded.
+ ///
+ /// Thread-safe: Feed is called from the message receiver thread, Begin from the plugin thread.
+ ///
+ internal sealed class ProxyFetchCorrelation
+ {
+ internal sealed class Pending
+ {
+ internal string CorrelationId;
+ internal CancellationTokenSource Deadline;
+ internal TaskCompletionSource Completion;
+ internal readonly Dictionary Chunks = new Dictionary();
+ internal ProxyFetchResponsePayload Meta;
+ internal bool Completed;
+ }
+
+ private readonly object _sync = new object();
+ private readonly Dictionary _pending = new Dictionary(StringComparer.Ordinal);
+
+ ///
+ /// Register a pending proxy-fetch request and return the task its reassembled outcome
+ /// completes on. If no terminal frame arrives before the deadline, the task completes with
+ /// a Timeout error result rather than staying pending forever.
+ ///
+ internal Task Begin(string correlationId, TimeSpan timeout)
+ {
+ lock (_sync)
+ {
+ var pending = new Pending
+ {
+ CorrelationId = correlationId,
+ Deadline = new CancellationTokenSource(timeout),
+ Completion = new TaskCompletionSource(),
+ };
+
+ var id = correlationId;
+ pending.Deadline.Token.Register(() =>
+ {
+ Complete(pending, new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Timeout,
+ ErrorMessage = $"No proxy-fetch response for {id} within the response deadline.",
+ });
+ });
+
+ _pending[correlationId] = pending;
+ return pending.Completion.Task;
+ }
+ }
+
+ ///
+ /// Route one response frame. Frames for correlation ids no longer pending (timed out or
+ /// already completed) or with malformed content are ignored, not thrown.
+ ///
+ internal void Feed(ProxyFetchResponsePayload frame)
+ {
+ if (frame == null || string.IsNullOrEmpty(frame.CorrelationId))
+ {
+ return;
+ }
+
+ lock (_sync)
+ {
+ if (!_pending.TryGetValue(frame.CorrelationId, out var pending) || pending.Completed)
+ {
+ return;
+ }
+
+ if (frame.Kind == ProxyFetchFrameKinds.Failure)
+ {
+ Complete(pending, new ProxyFetchResult
+ {
+ Success = false,
+ StatusCode = frame.StatusCode,
+ ErrorCode = string.IsNullOrEmpty(frame.ErrorCode) ? ProxyFetchErrorCodes.Error : frame.ErrorCode,
+ ErrorMessage = frame.ErrorMessage,
+ });
+ return;
+ }
+
+ if (frame.Kind == ProxyFetchFrameKinds.Response)
+ {
+ pending.Meta = frame;
+ }
+ else if (frame.Kind == ProxyFetchFrameKinds.Chunk)
+ {
+ try
+ {
+ pending.Chunks[frame.Sequence] = string.IsNullOrEmpty(frame.ContentBase64)
+ ? new byte[0]
+ : Convert.FromBase64String(frame.ContentBase64);
+ }
+ catch (FormatException)
+ {
+ return;
+ }
+ }
+ else
+ {
+ return;
+ }
+
+ TryFinishByCount(pending);
+ }
+ }
+
+ ///
+ /// Drop a pending request (the requester lost interest), completing its task cancelled so
+ /// nobody waits forever on it.
+ ///
+ internal void Abandon(string correlationId)
+ {
+ lock (_sync)
+ {
+ if (_pending.TryGetValue(correlationId, out var pending))
+ {
+ Complete(pending, new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy-fetch request was abandoned locally.",
+ });
+ }
+ }
+ }
+
+ private void TryFinishByCount(Pending pending)
+ {
+ if (pending.Meta == null)
+ {
+ return;
+ }
+
+ // A meta frame with a negative or oversize declaration is hostile/garbled input;
+ // allocating from it could throw on the message-receiver thread. The server-side
+ // fetcher never declares more than its own response cap.
+ if (pending.Meta.ChunkCount < 0 ||
+ pending.Meta.BodyLength < 0 ||
+ pending.Meta.BodyLength > ProxyFetchLimits.Default().MaxResponseBytes)
+ {
+ Complete(pending, new ProxyFetchResult
+ {
+ Success = false,
+ StatusCode = pending.Meta.StatusCode,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy-fetch metadata frame declared an unusable body size.",
+ });
+ return;
+ }
+
+ if (pending.Chunks.Count < pending.Meta.ChunkCount)
+ {
+ return;
+ }
+
+ var body = new byte[pending.Meta.BodyLength];
+ var offset = 0;
+ for (var sequence = 0; sequence < pending.Meta.ChunkCount; sequence++)
+ {
+ if (!pending.Chunks.TryGetValue(sequence, out var chunk))
+ {
+ return;
+ }
+
+ // Garbled or hostile frames must never throw on the message-receiver thread:
+ // chunk bytes that overrun the declared body length complete the exchange with
+ // an error outcome instead.
+ if (offset + chunk.Length > body.Length)
+ {
+ Complete(pending, new ProxyFetchResult
+ {
+ Success = false,
+ StatusCode = pending.Meta.StatusCode,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy-fetch chunk stream overran the declared body length.",
+ });
+ return;
+ }
+
+ Buffer.BlockCopy(chunk, 0, body, offset, chunk.Length);
+ offset += chunk.Length;
+ }
+
+ if (offset < body.Length)
+ {
+ Complete(pending, new ProxyFetchResult
+ {
+ Success = false,
+ StatusCode = pending.Meta.StatusCode,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy-fetch chunk stream carried less data than declared.",
+ });
+ return;
+ }
+
+ Complete(pending, new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = pending.Meta.StatusCode,
+ StatusText = pending.Meta.StatusText,
+ Headers = pending.Meta.Headers ?? new List(),
+ BodyLength = pending.Meta.BodyLength,
+ Body = body,
+ });
+ }
+
+ private void Complete(Pending pending, ProxyFetchResult result)
+ {
+ lock (_sync)
+ {
+ if (pending.Completed)
+ {
+ return;
+ }
+
+ pending.Completed = true;
+ _pending.Remove(pending.CorrelationId);
+ }
+
+ try
+ {
+ pending.Completion.TrySetResult(result);
+ }
+ finally
+ {
+ pending.Deadline.Cancel();
+ pending.Deadline.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/WebContent/ProxyFetchFramer.cs b/src/WebContent/ProxyFetchFramer.cs
new file mode 100644
index 0000000..8eaca60
--- /dev/null
+++ b/src/WebContent/ProxyFetchFramer.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+
+namespace WebContent
+{
+ ///
+ /// Pure seam between the fetcher's outcome and the message stream: turns a ProxyFetchResult
+ /// into the ordered frames the Event Server transmits back to the requester — one metadata
+ /// frame (status line + response headers + body length + chunk count), then base64 body
+ /// chunks. Ticket #53 acceptance: the body comes back chunked with status code and content
+ /// headers.
+ ///
+ internal static class ProxyFetchFramer
+ {
+ ///
+ /// Frame a successful fetch outcome. Chunk frames carry a 0-based sequence and the final
+ /// chunk is flagged, so a consumer can detect end of stream without waiting for the count.
+ ///
+ internal static List Frame(string correlationId, ProxyFetchResult result, int chunkSizeBytes)
+ {
+ var frames = new List();
+
+ if (!result.Success)
+ {
+ frames.Add(new ProxyFetchResponsePayload
+ {
+ CorrelationId = correlationId,
+ Kind = ProxyFetchFrameKinds.Failure,
+ Sequence = -1,
+ StatusCode = result.StatusCode,
+ ErrorCode = result.ErrorCode,
+ ErrorMessage = result.ErrorMessage,
+ });
+
+ return frames;
+ }
+
+ var body = result.Body ?? new byte[0];
+ if (chunkSizeBytes <= 0)
+ {
+ chunkSizeBytes = ProxyFetchLimits.DefaultChunkSizeBytes;
+ }
+
+ var chunkCount = (int)((body.Length + chunkSizeBytes - 1) / chunkSizeBytes);
+
+ frames.Add(new ProxyFetchResponsePayload
+ {
+ CorrelationId = correlationId,
+ Kind = ProxyFetchFrameKinds.Response,
+ Sequence = -1,
+ StatusCode = result.StatusCode,
+ StatusText = result.StatusText,
+ Headers = result.Headers ?? new List(),
+ BodyLength = result.BodyLength,
+ ChunkCount = chunkCount,
+ });
+
+ for (var index = 0; index < chunkCount; index++)
+ {
+ var offset = index * chunkSizeBytes;
+ var length = Math.Min(chunkSizeBytes, body.Length - offset);
+ var chunk = new byte[length];
+ Array.Copy(body, offset, chunk, 0, length);
+
+ frames.Add(new ProxyFetchResponsePayload
+ {
+ CorrelationId = correlationId,
+ Kind = ProxyFetchFrameKinds.Chunk,
+ Sequence = index,
+ IsLastChunk = index == chunkCount - 1,
+ ContentBase64 = Convert.ToBase64String(chunk),
+ });
+ }
+
+ return frames;
+ }
+ }
+}
diff --git a/src/WebContent/ProxyFetchTransport.cs b/src/WebContent/ProxyFetchTransport.cs
new file mode 100644
index 0000000..0f73682
--- /dev/null
+++ b/src/WebContent/ProxyFetchTransport.cs
@@ -0,0 +1,250 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+
+namespace WebContent
+{
+ ///
+ /// Server-side transport for the proxy-fetch contract (#53): performs one HTTP(S) GET from
+ /// the server network and enforces the ticket's bounds — a connect budget, a per-read stall
+ /// budget, and a maximum response size — independent of which runtime hosts the plugin (the
+ /// .NET Framework HttpWebRequest timeouts double as native bounds, but the explicit
+ /// task-budget races below make the behavior identical everywhere).
+ ///
+ /// A target HTTP error status (404 and friends) is still a successful fetch: the proxy
+ /// relays the target's status line, headers, and page.
+ ///
+ internal sealed class ProxyHttpFetcher
+ {
+ ///
+ /// GET the target under the given limits. Never throws: transport outcomes are reported in
+ /// the returned ProxyFetchResult so framing/logging can react to each failure class.
+ ///
+ internal async Task FetchAsync(Uri target, ProxyFetchLimits limits)
+ {
+ if (!ProxyPolicy.IsHttpUri(target))
+ {
+ return Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch target must be an http(s) url.");
+ }
+
+ var request = (HttpWebRequest)WebRequest.Create(target);
+ request.Method = "GET";
+ request.UserAgent = "CascadiaWebContentPlugin/MIP-proxy-fetcher";
+
+ // On the .NET Framework host these are native enforcement; elsewhere the explicit
+ // budgets below enforce the same bounds. The properties sit a full second past the
+ // budgets so native enforcement can never race (and pre-empt) the budgeted checks.
+ try
+ {
+ request.Timeout = limits.ConnectTimeoutMs + 1000;
+ request.ReadWriteTimeout = limits.ReadTimeoutMs + 1000;
+ }
+ catch (Exception)
+ {
+ // Property may be unsupported on compat stacks; the budgets cover enforcement.
+ }
+
+ HttpWebResponse response;
+ try
+ {
+ var responseTask = request.GetResponseAsync();
+ var winner = await Task.WhenAny(responseTask, Task.Delay(limits.ConnectTimeoutMs));
+ if (winner != responseTask)
+ {
+ try
+ {
+ request.Abort();
+ }
+ catch (Exception)
+ {
+ }
+
+ return Failure(ProxyFetchErrorCodes.Timeout, $"No response headers from {ProxyPolicy.CanonicalOrigin(target)} within {limits.ConnectTimeoutMs} ms.");
+ }
+
+ response = (HttpWebResponse)await responseTask;
+ }
+ catch (WebException webException) when (webException.Response is HttpWebResponse errorResponse)
+ {
+ // The target answered with an error status: relay it like any other response.
+ response = errorResponse;
+ }
+ catch (WebException webException)
+ {
+ return MapWebException(webException, target, limits);
+ }
+ catch (IOException ioException)
+ {
+ // The compat stack surfaces a refused/reset connection as IOException where the
+ // .NET Framework host reports WebException(ConnectFailure).
+ return Failure(ProxyFetchErrorCodes.Unreachable, $"{ProxyPolicy.CanonicalOrigin(target)}: {ioException.Message}");
+ }
+ catch (Exception exception)
+ {
+ try
+ {
+ request.Abort();
+ }
+ catch (Exception)
+ {
+ }
+
+ return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: {exception.Message}");
+ }
+
+ using (response)
+ {
+ return await ReadCappedBodyAsync(response, target, limits);
+ }
+ }
+
+ private static async Task ReadCappedBodyAsync(HttpWebResponse response, Uri target, ProxyFetchLimits limits)
+ {
+ var result = new ProxyFetchResult
+ {
+ Success = false,
+ StatusCode = (int)response.StatusCode,
+ StatusText = GetStatusText(response),
+ Headers = ReadHeaders(response),
+ };
+
+ var body = new MemoryStream();
+ var buffer = new byte[16 * 1024];
+
+ try
+ {
+ using (var stream = response.GetResponseStream())
+ {
+ while (true)
+ {
+ var readTask = stream.ReadAsync(buffer, 0, buffer.Length);
+ var winner = await Task.WhenAny(readTask, Task.Delay(limits.ReadTimeoutMs));
+ if (winner != readTask)
+ {
+ return Failure(ProxyFetchErrorCodes.Timeout, $"Body of {ProxyPolicy.CanonicalOrigin(target)} stalled for more than {limits.ReadTimeoutMs} ms.");
+ }
+
+ var read = await readTask;
+ if (read == 0)
+ {
+ break;
+ }
+
+ if (body.Length + read > limits.MaxResponseBytes)
+ {
+ return Failure(ProxyFetchErrorCodes.TooLarge, $"Response body from {ProxyPolicy.CanonicalOrigin(target)} exceeded the {limits.MaxResponseBytes} byte response cap.");
+ }
+
+ body.Write(buffer, 0, read);
+ }
+ }
+ }
+ catch (WebException webException)
+ {
+ return MapWebException(webException, target, limits);
+ }
+ catch (IOException ioException)
+ {
+ return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: stream failure: {ioException.Message}");
+ }
+
+ result.Success = true;
+ result.Body = body.ToArray();
+ result.BodyLength = body.Length;
+ return result;
+ }
+
+ private static List ReadHeaders(HttpWebResponse response)
+ {
+ var headers = new List();
+ foreach (var name in response.Headers.AllKeys)
+ {
+ headers.Add(new ProxyFetchHeader { Name = name, Value = response.Headers[name] });
+ }
+
+ // Content-Type/Content-Encoding live on the response object itself and may be missing
+ // from the raw collection on some stacks; carry them explicitly so content headers pass.
+ if (!string.IsNullOrEmpty(response.ContentType) && !HasHeader(headers, "Content-Type"))
+ {
+ headers.Add(new ProxyFetchHeader { Name = "Content-Type", Value = response.ContentType });
+ }
+
+ return headers;
+ }
+
+ private static bool HasHeader(List headers, string name)
+ {
+ foreach (var header in headers)
+ {
+ if (string.Equals(header.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string GetStatusText(HttpWebResponse response)
+ {
+ // The wire reason phrase is not publicly exposed on HttpWebResponse, so the status
+ // code name ("NotFound") is carried as the human-readable status text. The requester
+ // keys off StatusCode; this is display metadata only.
+ return response.StatusCode.ToString();
+ }
+
+ private static ProxyFetchResult MapWebException(WebException webException, Uri target, ProxyFetchLimits limits)
+ {
+ switch (webException.Status)
+ {
+ // A budget breach normally surfaces through the explicit task races above; a
+ // native Timeout that still reaches here is reported as a timeout, not confusion.
+ case WebExceptionStatus.Timeout:
+ return Failure(ProxyFetchErrorCodes.Timeout, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}");
+
+ case WebExceptionStatus.ConnectFailure:
+ case WebExceptionStatus.NameResolutionFailure:
+ case WebExceptionStatus.TrustFailure:
+ case WebExceptionStatus.SecureChannelFailure:
+ return Failure(ProxyFetchErrorCodes.Unreachable, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}");
+
+ // Compat stacks classify a refused socket as UnknownError; the socket exception
+ // inside separates those from genuinely unknown faults.
+ case WebExceptionStatus.UnknownError when IsSocketRefusedOrUnreachable(webException):
+ return Failure(ProxyFetchErrorCodes.Unreachable, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}");
+
+ default:
+ return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}");
+ }
+ }
+
+ private static bool IsSocketRefusedOrUnreachable(WebException webException)
+ {
+ for (var inner = webException.InnerException; inner != null; inner = inner.InnerException)
+ {
+ if (inner is SocketException socketException &&
+ (socketException.SocketErrorCode == SocketError.ConnectionRefused ||
+ socketException.SocketErrorCode == SocketError.NetworkUnreachable ||
+ socketException.SocketErrorCode == SocketError.HostUnreachable))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static ProxyFetchResult Failure(string errorCode, string message)
+ {
+ return new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = errorCode,
+ ErrorMessage = message,
+ };
+ }
+ }
+}
diff --git a/src/WebContent/ProxyPolicy.cs b/src/WebContent/ProxyPolicy.cs
index 7b70b15..e6e99b2 100644
--- a/src/WebContent/ProxyPolicy.cs
+++ b/src/WebContent/ProxyPolicy.cs
@@ -28,6 +28,16 @@ internal static bool TryNormalizeTarget(string entry, out string normalized)
return true;
}
+ ///
+ /// True for absolute http(s) uris — the scheme gate every proxy-fetch flow (admin harness,
+ /// Event Server request handling, transport) applies before touching the network.
+ ///
+ internal static bool IsHttpUri(Uri uri)
+ {
+ return uri != null &&
+ (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+ }
+
///
/// The canonical origin form used for both stored allowlist entries and request matching.
///
diff --git a/src/WebContent/Strings.Designer.cs b/src/WebContent/Strings.Designer.cs
index 093525d..83d20f1 100644
--- a/src/WebContent/Strings.Designer.cs
+++ b/src/WebContent/Strings.Designer.cs
@@ -473,5 +473,59 @@ internal static string ProxyDisplayEnabledPrefix {
return ResourceManager.GetString("ProxyDisplayEnabledPrefix", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Test proxy fetch.
+ ///
+ internal static string ProxyTestFetchLabel {
+ get {
+ return ResourceManager.GetString("ProxyTestFetchLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sends a proxy-fetch request from this Management Client across MIP messaging to the Event Server and reports what came back. Requires the Web Content plugin loaded on the Event Server (restart the Event Server service after deployment)..
+ ///
+ internal static string ProxyTestFetchHint {
+ get {
+ return ResourceManager.GetString("ProxyTestFetchHint", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Fetching through the Event Server....
+ ///
+ internal static string ProxyTestFetchRunning {
+ get {
+ return ResourceManager.GetString("ProxyTestFetchRunning", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The proxy fetch harness is not connected. Check the Event Server log (MIP.log) and restart the Management Client after a plugin deployment..
+ ///
+ internal static string ProxyTestFetchNotConnected {
+ get {
+ return ResourceManager.GetString("ProxyTestFetchNotConnected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to HTTP {0} - {1} bytes fetched in {2:0.0}s.
+ ///
+ internal static string ProxyTestFetchSuccessFormat {
+ get {
+ return ResourceManager.GetString("ProxyTestFetchSuccessFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Proxy fetch {0} after {1:0.0}s - {2}.
+ ///
+ internal static string ProxyTestFetchFailureFormat {
+ get {
+ return ResourceManager.GetString("ProxyTestFetchFailureFormat", resourceCulture);
+ }
+ }
}
}
diff --git a/src/WebContent/Strings.resx b/src/WebContent/Strings.resx
index c32f356..aace265 100644
--- a/src/WebContent/Strings.resx
+++ b/src/WebContent/Strings.resx
@@ -258,4 +258,22 @@
Enabled - allowlisted targets:
+
+ Test proxy fetch
+
+
+ Sends a proxy-fetch request from this Management Client across MIP messaging to the Event Server and reports what came back. Requires the Web Content plugin loaded on the Event Server (restart the Event Server service after deployment).
+
+
+ Fetching through the Event Server...
+
+
+ The proxy fetch harness is not connected. Check the Event Server log (MIP.log) and restart the Management Client after a plugin deployment.
+
+
+ HTTP {0} - {1} bytes fetched in {2:0.0}s
+
+
+ Proxy fetch {0} after {1:0.0}s - {2}
+
\ No newline at end of file
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index 1d760e0..113c67b 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -104,6 +104,12 @@
+
+
+
+
+
+
diff --git a/src/WebContent/WebContentPluginDefinition.cs b/src/WebContent/WebContentPluginDefinition.cs
index 4dfc6de..6a988af 100644
--- a/src/WebContent/WebContentPluginDefinition.cs
+++ b/src/WebContent/WebContentPluginDefinition.cs
@@ -6,6 +6,7 @@
using System.Threading.Tasks;
using VideoOS.Platform;
using VideoOS.Platform.Admin;
+using VideoOS.Platform.Background;
using VideoOS.Platform.Client;
using VideoOS.Platform.Messaging;
using WebContent.Admin;
@@ -25,6 +26,7 @@ public class WebContentPluginDefinition : PluginDefinition
private readonly List _workspaceMessageRegistrations = new List();
private readonly List _workSpacePlugins = new List();
private readonly WorkspacePluginFactory _workspacePluginFactory = new WorkspacePluginFactory();
+ private readonly List _backgroundPlugins = new List();
private DateTime _nextWorkspaceRetryUtc = DateTime.MinValue;
private DateTime _lastWorkspaceFailureLogUtc = DateTime.MinValue;
@@ -59,6 +61,23 @@ public override string VersionString
public override List ViewItemPlugins { get; } = new List();
+ ///
+ /// Server-side proxy fetcher (#53). The host instantiates background plugins for the
+ /// environments declared in TargetEnvironments, so this reaches the Event Server only;
+ /// Smart Client and Management Client hosts never run it.
+ ///
+ public override List BackgroundPlugins => _backgroundPlugins;
+
+ ///
+ /// The classic MIP messaging ids the proxy-fetch exchange uses (#53), registered in the
+ /// message id vocabulary so tooling sees them as plugin-owned traffic.
+ ///
+ public override List PluginDefinedMessageIds { get; } = new List
+ {
+ ProxyMessageIds.FetchRequestId,
+ ProxyMessageIds.FetchResponseId,
+ };
+
public override List WorkSpacePlugins
{
get
@@ -114,6 +133,10 @@ public override void Init()
switch (EnvironmentManager.Instance.EnvironmentType)
{
case EnvironmentType.Administration:
+ // Management Client harness for the proxy-fetch exchange (#53): lets the admin
+ // config page fire a test fetch from the MC across to the Event Server.
+ ProxyFetchClient.Active = new ProxyFetchClient();
+ ProxyFetchClient.Active.Setup(EnvironmentManager.Instance.MasterSite == null ? null : EnvironmentManager.Instance.MasterSite.ServerId);
break;
case EnvironmentType.SmartClient:
ViewItemPlugins.Add(new WebContentViewItemPlugin());
@@ -122,8 +145,13 @@ public override void Init()
EnsureWorkspacesLoaded(force: false, reason: "Smart Client Init");
break;
case EnvironmentType.Standalone:
- case EnvironmentType.Service:
case EnvironmentType.ManagementServer:
+ break;
+ case EnvironmentType.Service:
+ // Event Server: host the proxy-fetch background plugin. Nothing Smart Client-
+ // or view-specific may run here - the WebView2 view stack is never loaded.
+ _backgroundPlugins.Add(new ProxyFetchBackgroundPlugin());
+ break;
default:
break;
}
@@ -134,7 +162,15 @@ public override async void Close()
StopWorkspaceRetryTimer();
UnregisterWorkspaceRecoveryReceivers();
+ var proxyHarness = ProxyFetchClient.Active;
+ if (proxyHarness != null)
+ {
+ ProxyFetchClient.Active = null;
+ proxyHarness.Close();
+ }
+
ViewItemPlugins.Clear();
+ _backgroundPlugins.Clear();
lock (_workspaceSync)
{
_workSpacePlugins.Clear();
diff --git a/src/WebContent/plugin.def b/src/WebContent/plugin.def
index db26ee9..22abe19 100644
--- a/src/WebContent/plugin.def
+++ b/src/WebContent/plugin.def
@@ -1,4 +1,4 @@
-
+
From 35c4d62e8ae3a81f9758dc4521946b1f06cc7889 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Fri, 30 Oct 2026 15:11:35 -0700
Subject: [PATCH 004/187] fix: proxy-test harness resumed off the UI thread
after await, crashing MC on display (#53)
OnProxyTestFetch awaited with ConfigureAwait(false), so the result display and the finally block ran on a thread-pool thread; the WPF access threw Dispatcher.VerifyAccess and took down VideoOS.Administration. WPF event handlers must not ConfigureAwait(false): the continuation now resumes on the UI thread and the manual Dispatcher.Invoke re-dispatch is gone.
---
.../Admin/WorkspaceConfigPage.xaml.cs | 17 +++++++----------
1 file changed, 7 insertions(+), 10 deletions(-)
diff --git a/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs b/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
index 70fe19d..b35d328 100644
--- a/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
+++ b/src/WebContent/Admin/WorkspaceConfigPage.xaml.cs
@@ -233,8 +233,8 @@ private void OnProxyTargetsTextChanged(object sender, TextChangedEventArgs e)
/// Ticket #53 demo harness: sends a proxy-fetch request from the Management Client over
/// classic MIP messaging (ProxyFetchClient.Active, provisioned during Init) and shows the
/// status line, body size, and duration that came back from the Event Server fetcher.
- /// The await continues off the UI thread, so result display re-dispatches onto the
- /// page's dispatcher.
+ /// No ConfigureAwait(false) anywhere: this is a WPF event handler, and every statement
+ /// after the await - including the finally block - must stay on the UI thread.
///
private async void OnProxyTestFetch(object sender, RoutedEventArgs e)
{
@@ -259,13 +259,12 @@ private async void OnProxyTestFetch(object sender, RoutedEventArgs e)
var url = (txtProxyTestUrl.Text ?? string.Empty).Trim();
var stopwatch = Stopwatch.StartNew();
- var result = await client.FetchAsync(url).ConfigureAwait(false);
+ var result = await client.FetchAsync(url);
stopwatch.Stop();
- string outcome;
if (result.Success)
{
- outcome = string.Format(
+ lblProxyTestResult.Text = string.Format(
CultureInfo.InvariantCulture,
Strings.ProxyTestFetchSuccessFormat,
result.StatusCode,
@@ -274,24 +273,22 @@ private async void OnProxyTestFetch(object sender, RoutedEventArgs e)
}
else
{
- outcome = string.Format(
+ lblProxyTestResult.Text = string.Format(
CultureInfo.InvariantCulture,
Strings.ProxyTestFetchFailureFormat,
result.ErrorCode,
stopwatch.Elapsed.TotalSeconds,
result.ErrorMessage);
}
-
- Dispatcher.Invoke(() => lblProxyTestResult.Text = outcome);
}
catch (Exception exception)
{
- Dispatcher.Invoke(() => lblProxyTestResult.Text = string.Format(
+ lblProxyTestResult.Text = string.Format(
CultureInfo.InvariantCulture,
Strings.ProxyTestFetchFailureFormat,
ProxyFetchErrorCodes.Error,
0d,
- exception.Message));
+ exception.Message);
}
finally
{
From c496091503cadbce0fa3b9d32c959274fc65273c Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Fri, 30 Oct 2026 21:36:10 -0700
Subject: [PATCH 005/187] feat: per-workspace loopback forwarding proxy pinned
to WebView2, served by the Event Server fetcher (#54)
- LoopbackProxyListener: one ephemeral 127.0.0.1 listener per proxied
workspace browser profile; allowlist policy (403 off-list, 405 non-GET,
502 CONNECT, 400 malformed); https pinned with CONNECT refused - never
direct.
- ProxyBrowserArguments pins every scheme to the listener and disables the
<-loopback bypass; proxied profiles get their own WebView2 user-data dir.
- ProxyForwardCodec/Policy/Connection + ProxyWorkspaceProfile +
ProxyServerAllowlist as pure tested seams.
- Event Server fetcher hardening: server-side allowlist union over proxied
workspaces (closed before first resolve, stale-on-error).
- Round-trip fix (live-host diagnosis): responses are unicasted to the
requester via Message.ExternalMessageSourceEndPoint. The receive-callback
source parameter is null for our requests, and answering to a null
destination filter-routed frames to every FetchResponseId subscriber:
refusals vanished (both clients timed out) and fetched bodies leaked to
unrelated hosts (strange ProxyFetchResponsePayload deserialization errors).
Pinned by ProxyFetchReplyGuardTests.
- MC-10 probe (launch+login FlaUI) verified live on BEAST: non-allowlisted
origin -> NotAllowed 0.0s; allowlisted https://joshooaj.com -> HTTP 200,
37521 bytes, 0.3s. ES log gains the matching refusal marker.
---
AGENTS.md | 3 +-
docs/verification-matrix.md | 59 +++++-
.../ProxyBrowserArgumentsTests.cs | 33 ++++
.../ProxyFetchReplyGuardTests.cs | 59 ++++++
.../ProxyForwardCodecTests.cs | 144 ++++++++++++++
.../ProxyForwardConnectionTests.cs | 138 ++++++++++++++
.../ProxyForwardPolicyTests.cs | 74 +++++++
.../ProxyServerAllowlistTests.cs | 113 +++++++++++
.../ProxyWorkspaceProfileTests.cs | 47 +++++
src/WebContent.Tests/WebContent.Tests.csproj | 6 +
src/WebContent/Client/BrowserInstance.cs | 30 ++-
src/WebContent/Client/BrowserManager.cs | 170 ++++++++++++++++-
src/WebContent/Client/BrowserRegistry.cs | 7 +
.../Client/LoopbackProxyListener.cs | 112 +++++++++++
src/WebContent/Client/WebContentSettings.cs | 6 +
src/WebContent/Client/WebContentViewModel.cs | 33 +++-
src/WebContent/ProxyBrowserArguments.cs | 21 ++
src/WebContent/ProxyFetchBackgroundPlugin.cs | 91 ++++++++-
src/WebContent/ProxyFetchClient.cs | 4 +
src/WebContent/ProxyFetchContract.cs | 6 +
src/WebContent/ProxyForwardCodec.cs | 180 ++++++++++++++++++
src/WebContent/ProxyForwardConnection.cs | 158 +++++++++++++++
src/WebContent/ProxyForwardPolicy.cs | 65 +++++++
src/WebContent/ProxyServerAllowlist.cs | 120 ++++++++++++
src/WebContent/ProxyWorkspaceProfile.cs | 41 ++++
src/WebContent/WebContent.csproj | 7 +
src/WebContent/WebContentPluginDefinition.cs | 54 +++++-
.../McProxyFetchProbeTests.cs | 107 +++++++++++
28 files changed, 1862 insertions(+), 26 deletions(-)
create mode 100644 src/WebContent.Tests/ProxyBrowserArgumentsTests.cs
create mode 100644 src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
create mode 100644 src/WebContent.Tests/ProxyForwardCodecTests.cs
create mode 100644 src/WebContent.Tests/ProxyForwardConnectionTests.cs
create mode 100644 src/WebContent.Tests/ProxyForwardPolicyTests.cs
create mode 100644 src/WebContent.Tests/ProxyServerAllowlistTests.cs
create mode 100644 src/WebContent.Tests/ProxyWorkspaceProfileTests.cs
create mode 100644 src/WebContent/Client/LoopbackProxyListener.cs
create mode 100644 src/WebContent/ProxyBrowserArguments.cs
create mode 100644 src/WebContent/ProxyForwardCodec.cs
create mode 100644 src/WebContent/ProxyForwardConnection.cs
create mode 100644 src/WebContent/ProxyForwardPolicy.cs
create mode 100644 src/WebContent/ProxyServerAllowlist.cs
create mode 100644 src/WebContent/ProxyWorkspaceProfile.cs
create mode 100644 tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
diff --git a/AGENTS.md b/AGENTS.md
index 9820e3d..fa7d272 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,7 +26,8 @@ Smart Client MIP plugin (WebView2 browser view). Plugin project `src/WebContent`
2. Copy `output\WebContent\*` to `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin` — the `INSTALLFOLDER` from `src/Setup/setup.wxs`. The `VideoOS` root is deliberate: both `Milestone\MIPPlugins` and `VideoOS\MIPPlugins` are valid MIP roots (OEM compatibility).
3. Elevate the copy: `Start-Process -Verb runas -FilePath pwsh -ArgumentList '-NoProfile','-Command',''`. Leave off `-Wait` under a shell timeout; the UAC prompt is interactive, so launch and poll the destination.
4. Verify by file hash, not timestamp: `Copy-Item` preserves the source `LastWriteTime`, so a good copy can still look stale.
-5. Logs: Smart Client plugin `EnvironmentManager.Log` output lands in `%ProgramData%\Milestone\XProtect Smart Client\MIPLog.txt`; Management Client logs to `Logs\MIP.log` under its own ProgramData folder. `MIPLog.txt` can be absent mid-session or when client logging is disabled, so missing logs are no proof the plugin never ran.
+5. The Event Server machine (the proxy fetcher's home) is deployed the same way but remotely: `New-PSSession -ComputerName vms.joshooaj.com -Credential `, `Stop-Service MilestoneEventServerService` (its stop is slow — wait for `Stopped` before copying or the dll is still locked), `Copy-Item -ToSession`, `Start-Service`. WinRM works with those credentials (bare negotiate/Kerberos does not). An ES restart disposes every running client's `MessageCommunication` — desktop clients must be restarted afterwards or every `TransmitMessage` dies with `Call to TransmitMessage after call to Dispose?`.
+6. Logs (verified on BEAST): Smart Client plugin `EnvironmentManager.Log` output lands in `%ProgramData%\Milestone\XProtect Smart Client\MIPLog.txt` (top level, plus rolling `MIPLogs\MIPLog.N.txt` — there is no Smart Client `Logs\` folder there); Management Client logs to `Logs\MIP.log` under its own ProgramData folder; the Event Server writes under its ProgramData `logs\MIPLogs\` (Event Server tray icon → "Show MIP logs"). Caveat: at least one Smart Client install has been observed loading the plugin view into a separate `VideoOS.OAuth2TokenGenerator.exe` process rather than `Client.exe` — if a session's plugin entries are missing from `MIPLog.txt` but the view visibly runs, hunt for the actual hosting process before concluding the plugin never loaded. `MIP.log`/`MIPLog.txt` can be absent mid-session or when client logging is disabled, so missing logs are no proof the plugin never ran.
## Agent skills
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index f1c9283..401882f 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -34,6 +34,7 @@ is not evidence the plugin never ran.
| SBOM integrity | #25 | `SbomGenerationGuardTests` (PostBuildEvent keeps a quoted absolute `-bc "$(MSBuildProjectDirectory)"` component path and `-D true`; the original bug let the CWD-relative `-bc .\` point at the build drop, so zero packages were detected) | `.\build.ps1` throws unless `output\WebContent\_manifest\spdx_2.2\manifest.spdx.json` lists referenced packages; CI Build job shows `SBOM OK` and no "no packages detected" annotation |
| Proxy configuration roundtrip | #52 | `ProxyConfigurationTests` (definitions saved before the proxy existed mean proxy-off; toggle+targets survive the serialized roundtrip), `ProxyPolicyTests` (admin entries canonicalize to scheme/host/port origins; garbage rejected; exact-origin matching; duplicates/overlaps collapse), `WorkspacePropertiesTests` (JSON-blob and legacy paths emit canonical `ProxyEnabled`/`ProxyTargets`, hand-crafted target gibberish skipped) | See MC-9 below: admin toggle round-trips to the Smart Client properties page; invalid target save refused with log marker |
| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types are plugin-namespace `[Serializable]` with public fields — the whitelist case that keeps `MessageCommunicationSettings.config` unnecessary; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; timeout), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged |
+| Loopback forwarding proxy (Smart Client view consumes the fetch seam) | #54 | `ProxyForwardCodecTests` (absolute-form proxied request parsing; origin-form/non-http refused; CONNECT recognized by authority target; forwarded-response rendering: status line, content headers relayed, hop-by-hop dropped, Content-Length restated from the reassembled body; refusal pages), `ProxyForwardPolicyTests` (decision table via `ProxyPolicy`: allowlisted GET forwards, other origins 403, non-GET 405, CONNECT 502, malformed 400 — address-bar navigation follows the same rule), `ProxyForwardConnectionTests` (full stream flow: forward+relay, 403 without touching the fetcher, Unreachable→502, Timeout→504), `ProxyBrowserArgumentsTests` (every http/https/ws scheme pinned to the listener, `<-loopback` bypass off — never direct), `ProxyWorkspaceProfileTests` (enabled+targets → listener allowlist; duplicates collapse; enabled-with-no-targets still proxies and allows nothing; disabled → no profile), `ProxyServerAllowlistTests` (ES refuses origins no proxied workspace allowlists, proxy-off workspaces don't count, closed before first resolve, stale-on-error keeps serving the last snapshot) | See SC-1 below: with the drop deployed on both the Event Server machine and the Smart Client machine, and the client's route to the target killed, the proxied page still loads — proof the traffic source is the server, not the client |
## Manual host checks
@@ -109,15 +110,26 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
plugin's own namespace, which the messaging layer trusts by default. The escape hatch only
becomes necessary if a payload ever references a foreign assembly type — then that type would
have to be added to the config file next to the host executable.
+4. Response routing is unicast: the fetcher answers on `Message.ExternalMessageSourceEndPoint`,
+ which the messaging layer fills with the requester's endpoint FQID on every received message.
+ (The receive callback's `source` parameter is null for our requests — it only carries an
+ explicitly passed identity. Answering to a null destination instead filter-routes response
+ frames to EVERY client subscribed to `WebContent.Proxy.FetchResponse`: refusals time out
+ silently on the requester, fetched bodies leak to unrelated hosts, and processes without
+ WebContent.dll log `Unable to deserialize class:...ProxyFetchResponsePayload`. This was the
+ #54 live-host failure: both clients timed out despite a healthy fetcher.)
-### MC-10 Management Client proxy-fetch harness (#53)
+### MC-10 Management Client proxy-fetch harness (#53, hardened by #54)
1. Restart Management Client with the new drop. Open a Web Content workspace definition — the
proxy section now has a "Test proxy fetch" row (URL textbox + button).
-2. Enter a URL the **server** network can reach (e.g. `https://www.example.com/`) and click
- "Fetch via Event Server". Expected: status line shows 200 with body size and duration within
- a few seconds.
-3. Failure cases: enter `http://10.255.255.1/` (unreachable) → the result shows `Unreachable`
+2. Enter a URL whose origin is allowlisted on some proxy-enabled workspace (e.g.
+ `http://10.0.0.5:8080/`) and click "Fetch via Event Server". Expected: status line shows 200
+ with body size and duration within a few seconds.
+3. Failure cases: enter an origin no proxied workspace allowlists (e.g. `https://www.example.com/`)
+ → `NotAllowed` refusal, Event Server log `Proxy fetch request ... refused: The origin '...' is
+ not in the proxy allowlist of any proxied workspace.` (#54 server-side gate). Enter
+ `http://10.255.255.1/` on an allowlisted origin (unreachable) → the result shows `Unreachable`
after the ~5 s connect budget, and the Event Server log gains `Proxy fetch failed
(code=Unreachable, target=...)`. Enter `not a url` → `BadRequest` refusal logged as
`Proxy fetch request ... refused: the url was not an absolute http(s) uri`. Successes are
@@ -125,6 +137,39 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
4. If the Event Server has no plugin loaded, the harness shows the not-connected message without
sending anything.
+### SC-1 End-to-end proxied page load (#54)
+
+Requires the drop deployed on BOTH machines (Event Server machine + Smart Client machine per
+`AGENTS.md`) and a target device that the Smart Client machine can reach (a monitored-segment
+device or any http page — https is refused by design, see below).
+
+1. In Management Client, enable the proxy on a workspace and allowlist the target origin in
+ http form (e.g. `http://10.0.0.5:8080` — https targets would need CONNECT tunnels, which this
+ forwarder deliberately refuses). Save; log in to Smart Client and open the workspace. A proxy
+ toggle made mid-session is reconciled when the workspace tab is reopened (the retained browser
+ is rewired and its WebView recreated — expected log: `rewired to loopback listener` or
+ `rewired to direct routing`).
+2. Expected Smart Client log markers (MIPLog.txt): `Proxied browser : loopback listener on
+ 127.0.0.1:, N allowlisted target(s)`. The proxied profile gets its own WebView2
+ user-data folder (`...\proxied-`) and is isolated even when the isolate checkbox is off:
+ one browser process is shared per user-data folder, and the `--proxy-server` argument must not
+ collide with other workspaces' WebViews.
+3. Expected: the home page renders, and its images/CSS load — subresources go through the same
+ listener. Then block the client machine's route to the target (`New-NetFirewallRule` block,
+ or pull the client off that segment). Reload: the page MUST still render, while a direct
+ `Invoke-WebRequest http://10.0.0.5:8080/` from the client machine now fails. That asymmetry
+ is the proof the traffic originates on the Event Server machine.
+4. Address-bar navigation to a NON-allowlisted origin in the proxied workspace shows the proxy's
+ 403 page ("not in the workspace proxy allowlist"), and no request leaves for it (the fetcher
+ is never asked). Navigation to an https:// origin shows the 502 CONNECT-refusal page — https
+ is never taken direct. A POST/HTML form submit shows the 405 page.
+5. Event Server `Logs\MIP.log` during 3: successes are silent; failure cases (4) log the
+ refusal/`Proxy fetch failed` markers from ES-1/MC-10.
+6. Remove the view item from the layout, or exit the Smart Client: the listener stops with the
+ browser (`Proxy listener` log entries only on faults); `netstat -an | findstr ` shows
+ nothing listening afterwards. (A mere close of the view tab keeps a background-retained
+ browser — and its listener — alive for re-attach; that is existing browser lifecycle behavior.)
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
@@ -141,6 +186,8 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
| Inconsistent proxy-target canonicalization between admin and runtime flows (#52) | `ProxyPolicyTests` + `WorkspacePropertiesTests` (shared `ProxyPolicy` canonical form in both save and Build paths) |
| Fetch message ids renamed to something ending in "Command", or payloads moved to a non-whitelisted type, silently breaking SecurityLevel.Normal clients or deserialization (#53) | `ProxyFetchContractGuardTests` (id-suffix + serializability/whitelist pins), ES-1 |
| Chunk framer or correlation reassembler losing frames/order so the client sees truncated bodies (#53) | `ProxyFetchFramerTests`, `ProxyFetchCorrelationTests`, `ProxyFetchTransportTests` |
+| Proxied workspace silently reaching targets direct, or driving the ES fetcher at arbitrary origins (#54) | `ProxyForwardPolicyTests` (403/405/502 table), `ProxyBrowserArgumentsTests` (no scheme falls back to direct), `ProxyWorkspaceProfileTests` (enabled always proxies), `ProxyServerAllowlistTests` (ES-side union gate, closed before first resolve), SC-1 |
+| Reassembled body framed with the target's stale Content-Length or hop-by-hop headers, corrupting the rendered page (#54) | `ProxyForwardCodecTests` (Content-Length restated, hop-by-hop dropped) |
## Scope notes (deliberate)
@@ -149,4 +196,4 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
ever refactored, extract the policy into a pure class first and move MC-4's backoff
assertions into the suite.
- WebView2/Smart Client UI behavior (actual page render, WPF address-bar visuals) cannot be
- unit-tested at the pure seams; MC-1–MC-10 (plus ES-1) are the release gate for those.
+ unit-tested at the pure seams; MC-1–MC-10 (plus ES-1 and SC-1) are the release gate for those.
diff --git a/src/WebContent.Tests/ProxyBrowserArgumentsTests.cs b/src/WebContent.Tests/ProxyBrowserArgumentsTests.cs
new file mode 100644
index 0000000..fc293f6
--- /dev/null
+++ b/src/WebContent.Tests/ProxyBrowserArgumentsTests.cs
@@ -0,0 +1,33 @@
+using System;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #54 AC1: the proxied workspace's WebView2 environment is created with the browser
+ /// proxy-server argument pointed at the loopback listener. These tests pin the argument
+ /// builder: every http and https scheme is pinned to the listener (never direct) and
+ /// Chromium's implicit localhost bypass is turned off so even loopback targets follow the rule.
+ ///
+ public class ProxyBrowserArgumentsTests
+ {
+ [Fact]
+ public void PinsHttpAndHttpsTrafficToTheLoopbackListener()
+ {
+ var args = ProxyBrowserArguments.For(49312);
+
+ Assert.Contains("--proxy-server=http=http://127.0.0.1:49312;https=http://127.0.0.1:49312", args);
+ }
+
+ [Fact]
+ public void RemovesChromiumImplicitLoopbackBypass()
+ {
+ // By default Chromium bypasses proxies for localhost URLs; "<-loopback" re-enlists
+ // them so an allowlisted device on loopback still routes through the VMS fetcher.
+ // Values carry no spaces, so no quoting layer is involved.
+ var args = ProxyBrowserArguments.For(49312);
+
+ Assert.Contains("--proxy-bypass-list=<-loopback>", args);
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
new file mode 100644
index 0000000..0e52a08
--- /dev/null
+++ b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
@@ -0,0 +1,59 @@
+using System.IO;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #54 live-host diagnosis: the fetcher must answer each proxy-fetch request on the
+ /// requester's endpoint. The MIP messaging layer stamps every received message with the
+ /// sender's endpoint FQID in Message.ExternalMessageSourceEndPoint; the receive-callback
+ /// source parameter only carries an explicitly passed identity, which ProxyFetchClient does
+ /// not pass. Answering to a null destination instead filter-routes the frames to every client
+ /// subscribed to the response message id - refusals vanish (clients time out) and fetched
+ /// page bodies leak to unrelated hosts. These source guards pin the reply path; the live
+ /// proof stays MC-10/ES-1/SC-1 in docs/verification-matrix.md.
+ ///
+ public class ProxyFetchReplyGuardTests
+ {
+ private static string ReadFetcher()
+ {
+ return File.ReadAllText(Path.Combine(
+ RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyFetchBackgroundPlugin.cs"));
+ }
+
+ [Fact]
+ public void RequesterResolutionFallsBackToThePlatformFilledSourceEndPoint()
+ {
+ var fetcher = ReadFetcher();
+
+ Assert.Contains("source ?? message?.ExternalMessageSourceEndPoint", fetcher);
+ }
+
+ [Fact]
+ public void EveryReplyTargetsTheResolvedRequesterEndpoint()
+ {
+ var fetcher = ReadFetcher();
+
+ // The handler hands the resolved requester (never the raw callback source) to both
+ // reply paths.
+ Assert.Contains("TransmitFailure(payload.CorrelationId, requester,", fetcher);
+ Assert.DoesNotContain("TransmitFailure(payload.CorrelationId, source,", fetcher);
+ Assert.Contains("ServeFetchAsync(communication, payload, target, requester)", fetcher);
+ }
+
+ [Fact]
+ public void ResponseTransmitsNeverFallBackToNullDestinationBroadcast()
+ {
+ var fetcher = ReadFetcher();
+
+ // Success frames: unicast to the requester endpoint, and the frame loop is guarded so
+ // a request without a requester endpoint is logged, not broadcast.
+ Assert.Contains("if (requesterFqid == null)", fetcher);
+ Assert.Contains("ProxyMessageIds.FetchResponseId, frame), requesterFqid, null, null", fetcher);
+
+ // Failure frames: unicast to the requester as well (message, requester, null, null).
+ var collapsed = System.Text.RegularExpressions.Regex.Replace(fetcher, @"\s+", " ");
+ Assert.Contains("ErrorMessage = message, }), requester, null, null);", collapsed);
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyForwardCodecTests.cs b/src/WebContent.Tests/ProxyForwardCodecTests.cs
new file mode 100644
index 0000000..f571355
--- /dev/null
+++ b/src/WebContent.Tests/ProxyForwardCodecTests.cs
@@ -0,0 +1,144 @@
+using System;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #54: the loopback forwarding proxy parses the absolute-form HTTP requests Chromium
+ /// sends to a proxy ("GET http://host/path HTTP/1.1"). These tests pin the pure head-parsing
+ /// seam the socket listener feeds; the socket itself is verified live (SC-1).
+ ///
+ public class ProxyForwardCodecTests
+ {
+ [Fact]
+ public void ParsesAbsoluteFormGetRequestWithHeaders()
+ {
+ var head =
+ "GET http://10.0.0.5:8080/monitor/live?cam=2 HTTP/1.1\r\n" +
+ "Host: 10.0.0.5:8080\r\n" +
+ "Accept: text/html\r\n" +
+ "\r\n";
+
+ var parsed = ProxyForwardCodec.TryParseRequestHead(head, out var request, out var rejectReason);
+
+ Assert.True(parsed, rejectReason);
+ Assert.Equal("GET", request.Method);
+ Assert.Equal("http://10.0.0.5:8080/monitor/live?cam=2", request.Uri.AbsoluteUri);
+ Assert.Equal("text/html", request.Headers["Accept"]);
+ }
+
+ [Fact]
+ public void RefusesOriginFormRequestLineInsteadOfGuessingAnOrigin()
+ {
+ var head = "GET /index.html HTTP/1.1\r\nHost: 10.0.0.5:8080\r\n\r\n";
+
+ var parsed = ProxyForwardCodec.TryParseRequestHead(head, out var request, out var rejectReason);
+
+ Assert.False(parsed);
+ Assert.Null(request);
+ Assert.Contains("/index.html", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesNonHttpSchemeTargets()
+ {
+ // net472 Uri.TryCreate accepts path- and scheme-like junk as file:/gopher: uris, so
+ // the parser itself must gate on http(s), not defer the whole scheme check.
+ var head = "GET ftp://fileserver/share/report.pdf HTTP/1.1\r\n\r\n";
+
+ var parsed = ProxyForwardCodec.TryParseRequestHead(head, out var request, out var rejectReason);
+
+ Assert.False(parsed);
+ Assert.Null(request);
+ Assert.Contains("ftp", rejectReason);
+ }
+
+ [Fact]
+ public void RecognizesConnectRequestsByTheirAuthorityTarget()
+ {
+ // Chromium opens https routes with CONNECT host:port. The codec parses it out of the
+ // head so the decision layer can refuse it as a refused tunnel (502), not a 400.
+ var head = "CONNECT portal.example.com:443 HTTP/1.1\r\nProxy-Connection: keep-alive\r\n\r\n";
+
+ var parsed = ProxyForwardCodec.TryParseRequestHead(head, out var request, out var rejectReason);
+
+ Assert.True(parsed, rejectReason);
+ Assert.Equal("CONNECT", request.Method);
+ Assert.Equal("portal.example.com:443", request.RawTarget);
+ Assert.Null(request.Uri);
+ }
+
+ [Fact]
+ public void RendersForwardedResponseWithStatusAndContentHeaders()
+ {
+ // AC3: a reassembled chunk stream becomes a forwarded response with the target's
+ // status code and content headers. Body framing is the proxy's own Content-Length.
+ var result = new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new System.Collections.Generic.List
+ {
+ new ProxyFetchHeader { Name = "Content-Type", Value = "text/html" },
+ new ProxyFetchHeader { Name = "Server", Value = "device-httpd/1.2" },
+ },
+ BodyLength = 15,
+ Body = System.Text.Encoding.UTF8.GetBytes("device "),
+ };
+
+ var rendered = System.Text.Encoding.UTF8.GetString(ProxyForwardCodec.RenderForwardedResponse(result));
+
+ Assert.StartsWith("HTTP/1.1 200 OK\r\n", rendered);
+ Assert.Contains("Content-Type: text/html\r\n", rendered);
+ Assert.Contains("Server: device-httpd/1.2\r\n", rendered);
+ Assert.Contains("Content-Length: 15\r\n", rendered);
+ Assert.Contains("\r\n\r\ndevice ", rendered);
+ }
+
+ [Fact]
+ public void DropsHopByHopHeadersAndStaleFramingFromForwardedResponses()
+ {
+ // Hop-by-hop headers describe the fetcher's own connection, not the browser's; a
+ // stale Content-Length from the target would corrupt body framing after reassembly.
+ var result = new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new System.Collections.Generic.List
+ {
+ new ProxyFetchHeader { Name = "Connection", Value = "keep-alive" },
+ new ProxyFetchHeader { Name = "Transfer-Encoding", Value = "chunked" },
+ new ProxyFetchHeader { Name = "Proxy-Connection", Value = "keep-alive" },
+ new ProxyFetchHeader { Name = "Content-Length", Value = "999" },
+ new ProxyFetchHeader { Name = "Content-Type", Value = "image/jpeg" },
+ },
+ BodyLength = 3,
+ Body = new byte[] { 0xFF, 0xD8, 0x00 },
+ };
+
+ var rendered = System.Text.Encoding.UTF8.GetString(ProxyForwardCodec.RenderForwardedResponse(result));
+
+ Assert.DoesNotContain("keep-alive", rendered);
+ Assert.DoesNotMatch("(?im)^Proxy-Connection:", rendered);
+ Assert.DoesNotMatch("(?im)^Transfer-Encoding:", rendered);
+ Assert.Contains("Content-Length: 3\r\n", rendered);
+ Assert.DoesNotContain("Content-Length: 999", rendered);
+ }
+
+ [Fact]
+ public void RendersErrorResponseWithProxyExplanationBody()
+ {
+ // Refusals (403 not allowlisted, 405 non-GET, 502 refused tunnel) carry an html body
+ // the browser can show, framed by the proxy itself.
+ var rendered = System.Text.Encoding.UTF8.GetString(
+ ProxyForwardCodec.RenderErrorResponse(403, "Forbidden", "The origin 'http://evil.test' is not allowlisted."));
+
+ Assert.StartsWith("HTTP/1.1 403 Forbidden\r\n", rendered);
+ Assert.Contains("Content-Type: text/html; charset=utf-8\r\n", rendered);
+ Assert.Contains("The origin 'http://evil.test' is not allowlisted.", rendered);
+ Assert.Matches("(?i)Connection: close", rendered);
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyForwardConnectionTests.cs b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
new file mode 100644
index 0000000..eb9b9d3
--- /dev/null
+++ b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #54: the connection-handling flow over a byte stream — read the request head, run
+ /// the decision table, fetch or refuse, render the answer. The stream stands in for the
+ /// loopback socket; the TcpListener accept loop around it is host-coupled and verified live.
+ ///
+ public class ProxyForwardConnectionTests
+ {
+ private static readonly string[] Allowlist = { "http://10.0.0.5:8080" };
+
+ [Fact]
+ public async Task ForwardsAllowlistedGetAndRelaysTheFetchedPage()
+ {
+ var requests = new List();
+ Func> fetch = url =>
+ {
+ requests.Add(url);
+ return Task.FromResult(new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new List { new ProxyFetchHeader { Name = "Content-Type", Value = "text/html" } },
+ BodyLength = 5,
+ Body = Encoding.UTF8.GetBytes("paged"),
+ });
+ };
+
+ var response = await Handle("GET http://10.0.0.5:8080/index.html HTTP/1.1\r\nAccept: */*\r\n\r\n", fetch);
+
+ Assert.Equal(new[] { "http://10.0.0.5:8080/index.html" }, requests);
+ Assert.StartsWith("HTTP/1.1 200 OK\r\n", response);
+ Assert.Contains("\r\n\r\npaged", response);
+ }
+
+ [Fact]
+ public async Task RefusesNonAllowlistedNavigationWith403WithoutTouchingTheFetcher()
+ {
+ var fetchCalled = false;
+ Func> fetch = url =>
+ {
+ fetchCalled = true;
+ return Task.FromResult(new ProxyFetchResult { Success = false });
+ };
+
+ var response = await Handle("GET http://elsewhere.test/page HTTP/1.1\r\n\r\n", fetch);
+
+ Assert.False(fetchCalled);
+ Assert.StartsWith("HTTP/1.1 403 Forbidden\r\n", response);
+ Assert.Contains("elsewhere.test", response);
+ }
+
+ [Fact]
+ public async Task SurfacesUnreachableFetchFailuresAs502WithTheFetchersReason()
+ {
+ Func> fetch = url => Task.FromResult(new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Unreachable,
+ ErrorMessage = "10.0.0.5:8080: no route to host",
+ });
+
+ var response = await Handle("GET http://10.0.0.5:8080/gone HTTP/1.1\r\n\r\n", fetch);
+
+ Assert.StartsWith("HTTP/1.1 502 Bad Gateway\r\n", response);
+ Assert.Contains("no route to host", response);
+ }
+
+ [Fact]
+ public async Task MapsFetchTimeoutsTo504()
+ {
+ Func> fetch = url => Task.FromResult(new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Timeout,
+ ErrorMessage = "no response headers within 5000 ms",
+ });
+
+ var response = await Handle("GET http://10.0.0.5:8080/slow HTTP/1.1\r\n\r\n", fetch);
+
+ Assert.StartsWith("HTTP/1.1 504 Gateway Timeout\r\n", response);
+ }
+
+ [Fact]
+ public async Task RelaysTheFramedChunkStreamThroughCorrelationIntoTheForwardedResponse()
+ {
+ // AC4's round trip on the existing #53 doubles: the fetcher's meta+chunk frames feed
+ // the correlator exactly as the MIP receiver thread would, and the reassembled
+ // outcome is what gets framed back to the browser.
+ var fetched = new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new List { new ProxyFetchHeader { Name = "Content-Type", Value = "text/css" } },
+ BodyLength = 9,
+ Body = Encoding.UTF8.GetBytes("body{red}"),
+ };
+
+ Func> fetch = url =>
+ {
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("exchange-1", TimeSpan.FromSeconds(5));
+ foreach (var frame in ProxyFetchFramer.Frame("exchange-1", fetched, 4))
+ {
+ correlation.Feed(frame);
+ }
+
+ return completion;
+ };
+
+ var response = await Handle("GET http://10.0.0.5:8080/theme.css HTTP/1.1\r\n\r\n", fetch);
+
+ Assert.StartsWith("HTTP/1.1 200 OK\r\n", response);
+ Assert.Contains("Content-Type: text/css\r\n", response);
+ Assert.Contains("Content-Length: 9\r\n", response);
+ Assert.Contains("\r\n\r\nbody{red}", response);
+ }
+
+ private static async Task Handle(string request, Func> fetch)
+ {
+ using (var input = new MemoryStream(Encoding.UTF8.GetBytes(request)))
+ using (var output = new MemoryStream())
+ {
+ await ProxyForwardConnection.HandleAsync(input, output, Allowlist, fetch, message => { });
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyForwardPolicyTests.cs b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
new file mode 100644
index 0000000..6e40643
--- /dev/null
+++ b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
@@ -0,0 +1,74 @@
+using System;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #54 AC4: the proxied decision is taken by the pure policy class from the config
+ /// ticket (#52) — allowlisted origins forward, everything else is refused with an explicit
+ /// HTTP status the browser can show. Address-bar navigation follows the same table as
+ /// page subresources because both arrive as proxied requests.
+ ///
+ public class ProxyForwardPolicyTests
+ {
+ private static readonly string[] Allowlist = { "http://10.0.0.5:8080" };
+
+ [Fact]
+ public void ForwardsGetRequestsToAllowlistedOrigins()
+ {
+ var head = "GET http://10.0.0.5:8080/monitor/live HTTP/1.1\r\nHost: 10.0.0.5:8080\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.Forward, decision);
+ Assert.Equal("http://10.0.0.5:8080/monitor/live", request.Uri.AbsoluteUri);
+ Assert.Null(rejectReason);
+ }
+
+ [Fact]
+ public void RefusesNonAllowlistedOriginsEvenForAddressBarNavigation()
+ {
+ var head = "GET http://somewhere.else.test/dashboard HTTP/1.1\r\nHost: somewhere.else.test\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.OriginNotAllowed, decision);
+ Assert.Contains("somewhere.else.test", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesMethodsWithBodyInsteadOfForwardingThem()
+ {
+ // The Event Server fetcher (#53) is GET-only; a POST must be refused locally with an
+ // explicit 405, never quietly forwarded and never answered from the client machine.
+ var head = "POST http://10.0.0.5:8080/login HTTP/1.1\r\nContent-Length: 12\r\n\r\nuser=x&pwd=y";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.MethodNotAllowed, decision);
+ Assert.Contains("POST", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesConnectTunnelsWithAnExplanation()
+ {
+ // https would reach the listener as CONNECT; a CONNECT-less forwarder cannot relay it,
+ // and the browser must never be allowed to go direct instead.
+ var head = "CONNECT portal.example.com:443 HTTP/1.1\r\nProxy-Connection: keep-alive\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(new[] { "https://portal.example.com" }, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.ConnectRefused, decision);
+ Assert.Contains("portal.example.com:443", rejectReason);
+ }
+
+ [Fact]
+ public void RejectsMalformedProxyRequestLines()
+ {
+ var decision = ProxyForwardPolicy.Decide(Allowlist, "GARBAGE", out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.BadRequest, decision);
+ Assert.Null(request);
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyServerAllowlistTests.cs b/src/WebContent.Tests/ProxyServerAllowlistTests.cs
new file mode 100644
index 0000000..83fca9a
--- /dev/null
+++ b/src/WebContent.Tests/ProxyServerAllowlistTests.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #54 server-side hardening: the Event Server fetcher refuses urls outside the union
+ /// of every proxied workspace's allowlist, so a crafted fetch request cannot drive the server
+ /// network at arbitrary targets. The snapshot comes from workspace item properties in
+ /// production; these tests feed property dictionaries directly.
+ ///
+ public class ProxyServerAllowlistTests
+ {
+ private static readonly Action Sink = _ => { };
+
+ private static Dictionary WorkspaceProperties(bool enabled, params string[] targetJsonObjects)
+ {
+ var targetsJson = "[" + string.Join(",", targetJsonObjects) + "]";
+ return new Dictionary
+ {
+ ["WorkspaceConfig"] =
+ "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0,\"settings\":" +
+ "{\"homeAddress\":\"http://x/\",\"proxyEnabled\":" + (enabled ? "true" : "false") +
+ ",\"proxyTargets\":" + targetsJson + "}}]}",
+ };
+ }
+
+ private static string Target(string scheme, string host, int port) =>
+ $"{{\"scheme\":\"{scheme}\",\"host\":\"{host}\",\"port\":{port}}}";
+
+ [Fact]
+ public void ForwardsOriginsAllowlistedByAnyProxiedWorkspace()
+ {
+ var allowlist = new ProxyServerAllowlist(
+ () => new List>
+ {
+ WorkspaceProperties(true, Target("http", "10.0.0.5", 8080)),
+ WorkspaceProperties(true, Target("https", "portal.test", 443)),
+ },
+ Sink);
+
+ Assert.True(allowlist.IsAllowed(new Uri("http://10.0.0.5:8080/monitor/live"), out _));
+ Assert.True(allowlist.IsAllowed(new Uri("https://portal.test/dashboard"), out _));
+ }
+
+ [Fact]
+ public void RefusesOriginsNoProxiedWorkspaceAllowlists()
+ {
+ var allowlist = new ProxyServerAllowlist(
+ () => new List>
+ {
+ WorkspaceProperties(true, Target("http", "10.0.0.5", 8080)),
+ },
+ Sink);
+
+ Assert.False(allowlist.IsAllowed(new Uri("http://metadata.internal.latest/"), out var reason));
+ Assert.Contains("allowlist", reason);
+ }
+
+ [Fact]
+ public void TargetsOfWorkspacesWithTheProxyOffDoNotCount()
+ {
+ var allowlist = new ProxyServerAllowlist(
+ () => new List>
+ {
+ WorkspaceProperties(false, Target("http", "10.0.0.5", 8080)),
+ },
+ Sink);
+
+ Assert.False(allowlist.IsAllowed(new Uri("http://10.0.0.5:8080/"), out _));
+ }
+
+ [Fact]
+ public void FailsClosedWhenTheConfigurationHasNeverResolved()
+ {
+ var allowlist = new ProxyServerAllowlist(
+ () => throw new InvalidOperationException("Configuration service is not available."),
+ Sink);
+
+ Assert.False(allowlist.IsAllowed(new Uri("http://10.0.0.5:8080/"), out var reason));
+ Assert.Contains("Configuration", reason);
+ }
+
+ [Fact]
+ public void KeepsServingTheLastKnownAllowlistWhenTheConfigServiceFlaps()
+ {
+ var resolverThrows = false;
+ var allowlist = new ProxyServerAllowlist(
+ () =>
+ {
+ if (resolverThrows)
+ {
+ throw new InvalidOperationException("transient");
+ }
+
+ return new List>
+ {
+ WorkspaceProperties(true, Target("http", "10.0.0.5", 8080)),
+ };
+ },
+ Sink);
+
+ Assert.True(allowlist.IsAllowed(new Uri("http://10.0.0.5:8080/"), out _));
+ resolverThrows = true;
+
+ // A transient config-service failure must not break pages that were fine a moment ago,
+ // but the stale snapshot is still the gate: unrelated origins stay refused.
+ Assert.True(allowlist.IsAllowed(new Uri("http://10.0.0.5:8080/page"), out _));
+ Assert.False(allowlist.IsAllowed(new Uri("http://elsewhere.test/"), out _));
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyWorkspaceProfileTests.cs b/src/WebContent.Tests/ProxyWorkspaceProfileTests.cs
new file mode 100644
index 0000000..da56fee
--- /dev/null
+++ b/src/WebContent.Tests/ProxyWorkspaceProfileTests.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Linq;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #54: the per-view proxy state fed into browser-profile creation. The admin stores
+ /// ProxyEnabled plus a canonical comma-separated ProxyTargets property (the #52 seeding path);
+ /// this class turns that into the listener's allowlist. Enabled with no usable targets still
+ /// proxies — everything then refuses, honoring "never direct" — and a disabled proxy creates
+ /// no profile at all.
+ ///
+ public class ProxyWorkspaceProfileTests
+ {
+ [Fact]
+ public void DisabledProxyCreatesNoProfile()
+ {
+ Assert.False(ProxyWorkspaceProfile.TryCreate(false, "http://10.0.0.5:8080", out var profile));
+ Assert.Null(profile);
+ }
+
+ [Fact]
+ public void EnabledProxyCarriesCanonicalTargetsAsTheListenerAllowlist()
+ {
+ Assert.True(ProxyWorkspaceProfile.TryCreate(true, "http://10.0.0.5:8080,https://portal.test:8443", out var profile));
+ Assert.Equal(new[] { "http://10.0.0.5:8080", "https://portal.test:8443" }, profile.CanonicalTargets);
+ }
+
+ [Fact]
+ public void DuplicateAndOverlappingEntriesCollapseOntoOneOrigin()
+ {
+ Assert.True(ProxyWorkspaceProfile.TryCreate(true, "http://10.0.0.5:8080,http://10.0.0.5:8080/, HTTP://10.0.0.5:8080", out var profile));
+ Assert.Equal(new[] { "http://10.0.0.5:8080" }, profile.CanonicalTargets);
+ }
+
+ [Fact]
+ public void EnabledProxyWithNoUsableTargetsStillProxiesAndAllowsNothing()
+ {
+ // Never direct: an enabled proxy with a blank or junk target list still routes the
+ // browser through the listener, which then refuses every request. (A bare host is a
+ // legitimate #52 shorthand, so the junk entry must be truly un-parseable.)
+ Assert.True(ProxyWorkspaceProfile.TryCreate(true, ", ,not host at all,", out var profile));
+ Assert.Empty(profile.CanonicalTargets);
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 204df7f..1c5c89b 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -34,6 +34,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/WebContent/Client/BrowserInstance.cs b/src/WebContent/Client/BrowserInstance.cs
index 44d9bd8..9331f51 100644
--- a/src/WebContent/Client/BrowserInstance.cs
+++ b/src/WebContent/Client/BrowserInstance.cs
@@ -122,6 +122,22 @@ public BrowserInstance(WebView2 browser, Guid id)
Id = id;
}
+ ///
+ /// Ticket #54: loopback forwarding proxy bound to this browser profile. The arguments are
+ /// re-applied on every WebView recreation, the listener survives recreation, and it stops
+ /// when the browser instance is disposed.
+ ///
+ internal LoopbackProxyListener ProxyListener { get; set; }
+
+ internal string ProxyBrowserArguments { get; set; }
+
+ ///
+ /// Distinct WebView2 user-data folder for a proxied profile (its own browser process, so
+ /// the --proxy-server argument cannot collide with shared-folder WebViews); null when the
+ /// browser routes direct.
+ ///
+ internal string ProxyUserDataFolder { get; set; }
+
public void Initialize()
{
lock (_sync)
@@ -363,6 +379,10 @@ public void Dispose()
{
EnvironmentManager.Instance.Log(false, nameof(BrowserInstance), $"Disposing browser {Id}. ClearBrowsingDataOnExit={ClearBrowsingDataOnExit}.");
Close(ClearBrowsingDataOnExit);
+
+ var proxyListener = ProxyListener;
+ ProxyListener = null;
+ proxyListener?.Stop();
}
public void RecreateWebView(Uri initialAddress, bool isolateInstances)
@@ -370,14 +390,20 @@ public void RecreateWebView(Uri initialAddress, bool isolateInstances)
EnvironmentManager.Instance.Log(false, nameof(BrowserInstance), $"Recreating WebView for browser {Id}. isolateInstances={isolateInstances}, address={initialAddress}.");
Close(clearData: false, disposeWebView: true);
- var userDataFolder = BrowserProfileIdentity.GetUserDataFolder();
+ var userDataFolder = ProxyUserDataFolder ?? BrowserProfileIdentity.GetUserDataFolder();
+
+ // A proxied browser keeps isolation even if the setting says otherwise: its
+ // --proxy-server argument belongs to one profile only, and sharing the default
+ // profile would let the proxy (or its absence) leak across workspaces (#54).
+ var isolated = BrowserManager.ShouldIsolateProfile(isolateInstances, ProxyBrowserArguments != null);
WebView = new WebView2
{
CreationProperties = new CoreWebView2CreationProperties
{
UserDataFolder = userDataFolder,
- ProfileName = isolateInstances ? Id.ToString() : string.Empty,
+ ProfileName = isolated ? Id.ToString() : string.Empty,
+ AdditionalBrowserArguments = ProxyBrowserArguments,
},
Source = initialAddress ?? WebContentConfiguration.CreateNavigationUri(Strings.HomeAddress),
};
diff --git a/src/WebContent/Client/BrowserManager.cs b/src/WebContent/Client/BrowserManager.cs
index bbda208..a490ca3 100644
--- a/src/WebContent/Client/BrowserManager.cs
+++ b/src/WebContent/Client/BrowserManager.cs
@@ -1,5 +1,6 @@
using Microsoft.Web.WebView2.Wpf;
using System;
+using System.Threading.Tasks;
using VideoOS.Platform;
namespace WebContent.Client
@@ -14,10 +15,22 @@ public static BrowserInstance GetBrowser(Guid id)
}
public static BrowserInstance GetBrowser(Guid id, Uri initialAddress, bool isIsolated)
+ {
+ return GetBrowser(id, initialAddress, isIsolated, null);
+ }
+
+ ///
+ /// Ticket #54: when is present the browser profile gets a
+ /// dedicated loopback forwarding listener and the --proxy-server argument pointed at it,
+ /// and isolation is forced so the argument cannot leak into another workspace's browser.
+ /// If the listener cannot start, no browser is created at all — an unproxied browser for
+ /// a proxied workspace would silently defeat the "never direct" rule.
+ ///
+ internal static BrowserInstance GetBrowser(Guid id, Uri initialAddress, bool isIsolated, ProxyWorkspaceProfile proxyProfile)
{
try
{
- return _registry.GetOrCreate(id, guid => CreateBrowser(guid, initialAddress, isIsolated));
+ return _registry.GetOrCreate(id, guid => CreateBrowser(guid, initialAddress, isIsolated, proxyProfile));
}
catch (Exception ex)
{
@@ -26,19 +39,156 @@ public static BrowserInstance GetBrowser(Guid id, Uri initialAddress, bool isIso
return null;
}
- private static BrowserInstance CreateBrowser(Guid id, Uri initialAddress, bool isIsolated)
+ private static BrowserInstance CreateBrowser(Guid id, Uri initialAddress, bool isIsolated, ProxyWorkspaceProfile proxyProfile)
+ {
+ LoopbackProxyListener listener = null;
+ string proxyArguments = null;
+ string proxyUserDataFolder = null;
+ if (proxyProfile != null)
+ {
+ listener = new LoopbackProxyListener(proxyProfile.CanonicalTargets, FetchThroughMessaging);
+ listener.Start();
+ proxyArguments = ProxyBrowserArguments.For(listener.Port);
+ proxyUserDataFolder = ProxiedUserDataFolder(id);
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(BrowserManager),
+ $"Proxied browser {id}: loopback listener on 127.0.0.1:{listener.Port}, {proxyProfile.CanonicalTargets.Count} allowlisted target(s).");
+ }
+
+ try
+ {
+ var instance = new WebView2
+ {
+ CreationProperties = new CoreWebView2CreationProperties
+ {
+ // A proxied profile gets its own user-data folder: WebView2 shares one
+ // browser process per user-data folder, and additional browser arguments
+ // must match across every WebView in it — a proxied profile cannot share
+ // the default folder with unprefixed browsers, nor two proxied profiles
+ // with different listener ports (#54).
+ UserDataFolder = proxyUserDataFolder ?? BrowserProfileIdentity.GetUserDataFolder(),
+ ProfileName = ShouldIsolateProfile(isIsolated, proxyArguments != null) ? id.ToString() : string.Empty,
+ AdditionalBrowserArguments = proxyArguments,
+ },
+ Source = initialAddress,
+ };
+
+ var browser = new BrowserInstance(instance, id);
+ browser.ProxyListener = listener;
+ browser.ProxyBrowserArguments = proxyArguments;
+ browser.ProxyUserDataFolder = proxyUserDataFolder;
+ return browser;
+ }
+ catch (Exception)
+ {
+ listener?.Stop();
+ throw;
+ }
+ }
+
+ ///
+ /// One rule for both creation and recreation: a proxied browser is always profile-isolated,
+ /// so its --proxy-server argument cannot leak into another workspace's browser.
+ ///
+ internal static bool ShouldIsolateProfile(bool isolateSetting, bool proxied)
+ {
+ return isolateSetting || proxied;
+ }
+
+ ///
+ /// Reconcile an existing (background-retained) browser with the workspace's current proxy
+ /// config: an admin toggling the proxy on/off mid-session must not leave the reopened view
+ /// on the other configuration's routing ("never direct"). Starts/stops the listener and
+ /// switches the profile arguments; returns true when the WebView must be recreated to pick
+ /// the change up. A target-list edit alone needs no recreation — the listener gate is live.
+ ///
+ internal static bool ApplyProxyProfile(BrowserInstance browser, ProxyWorkspaceProfile proxyProfile)
+ {
+ if (browser == null)
+ {
+ return false;
+ }
+
+ if (proxyProfile != null)
+ {
+ if (browser.ProxyBrowserArguments != null)
+ {
+ browser.ProxyListener?.UpdateTargets(proxyProfile.CanonicalTargets);
+ return false;
+ }
+
+ var listener = new LoopbackProxyListener(proxyProfile.CanonicalTargets, FetchThroughMessaging);
+ listener.Start();
+ browser.ProxyListener = listener;
+ browser.ProxyBrowserArguments = ProxyBrowserArguments.For(listener.Port);
+ browser.ProxyUserDataFolder = ProxiedUserDataFolder(browser.Id);
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(BrowserManager),
+ $"Proxied browser {browser.Id} rewired to loopback listener 127.0.0.1:{listener.Port} (proxy enabled since the browser was created).");
+ return true;
+ }
+
+ if (browser.ProxyBrowserArguments == null)
+ {
+ return false;
+ }
+
+ var stopped = browser.ProxyListener;
+ browser.ProxyListener = null;
+ browser.ProxyBrowserArguments = null;
+ browser.ProxyUserDataFolder = null;
+ stopped?.Stop();
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(BrowserManager),
+ $"Browser {browser.Id} rewired to direct routing (proxy disabled since the browser was created).");
+ return true;
+ }
+
+ private static string ProxiedUserDataFolder(Guid id)
{
- var instance = new WebView2
+ return System.IO.Path.Combine(BrowserProfileIdentity.GetUserDataFolder(), "proxied-" + id);
+ }
+
+ ///
+ /// The listener's fetch seam: proxy-fetch over classic MIP messaging to the Event Server
+ /// fetcher (#53). An unconnected channel is an explicit fetch failure, never a direct hit.
+ ///
+ private static Task FetchThroughMessaging(string url)
+ {
+ var client = ProxyFetchClient.Active;
+ if (client == null || !client.IsReady)
{
- CreationProperties = new CoreWebView2CreationProperties
+ return Task.FromResult(new ProxyFetchResult
{
- UserDataFolder = BrowserProfileIdentity.GetUserDataFolder(),
- ProfileName = isIsolated ? id.ToString() : string.Empty,
- },
- Source = initialAddress,
- };
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The Smart Client is not connected to the Event Server proxy fetcher.",
+ });
+ }
- return new BrowserInstance(instance, id);
+ return client.FetchAsync(url);
+ }
+
+ ///
+ /// Stops every live loopback proxy listener (plugin teardown), so no listener outlives the
+ /// plugin even when browsers are dropped without an explicit reclaim.
+ ///
+ public static void StopAllProxyListeners()
+ {
+ foreach (var browser in _registry.Snapshot())
+ {
+ try
+ {
+ browser?.ProxyListener?.Stop();
+ }
+ catch (Exception ex)
+ {
+ EnvironmentManager.Instance.ExceptionHandler(nameof(StopAllProxyListeners), ex);
+ }
+ }
}
///
diff --git a/src/WebContent/Client/BrowserRegistry.cs b/src/WebContent/Client/BrowserRegistry.cs
index d5c3dd6..39a23dc 100644
--- a/src/WebContent/Client/BrowserRegistry.cs
+++ b/src/WebContent/Client/BrowserRegistry.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
namespace WebContent.Client
{
@@ -36,6 +37,12 @@ public bool TryGet(Guid id, out T instance)
return _instances.TryGetValue(id, out instance);
}
+ /// Copy of the currently tracked instances (teardown sweeps, e.g. stopping proxy listeners).
+ public List Snapshot()
+ {
+ return new List(_instances.Values);
+ }
+
public T GetOrCreate(Guid id, Func factory)
{
if (_instances.TryGetValue(id, out var existing))
diff --git a/src/WebContent/Client/LoopbackProxyListener.cs b/src/WebContent/Client/LoopbackProxyListener.cs
new file mode 100644
index 0000000..515d924
--- /dev/null
+++ b/src/WebContent/Client/LoopbackProxyListener.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using VideoOS.Platform;
+
+namespace WebContent.Client
+{
+ ///
+ /// Loopback-only forwarding proxy listener for one proxied workspace browser profile (#54):
+ /// bound to 127.0.0.1 with an OS-assigned (ephemeral) port, it is the address the browser
+ /// profile's --proxy-server argument points at. Every request head is handled by the pure
+ /// flow — parse, policy, fetch, render — so the only
+ /// socket-coupled code here is accept/read/write, verified live (verification-matrix SC-1).
+ ///
+ /// Started when a proxied browser is created and stopped when that browser is disposed;
+ /// WebView2 environment recreation reuses the same listener and port.
+ ///
+ internal sealed class LoopbackProxyListener
+ {
+ private readonly Func> _fetch;
+ private IReadOnlyList _canonicalTargets;
+ private TcpListener _listener;
+ private volatile bool _stopping;
+
+ internal int Port { get; private set; }
+
+ internal LoopbackProxyListener(IReadOnlyList canonicalTargets, Func> fetch)
+ {
+ _canonicalTargets = canonicalTargets ?? new string[0];
+ _fetch = fetch ?? throw new ArgumentNullException(nameof(fetch));
+ }
+
+ /// Swap the allowlist gate live (an admin target edit needs no browser restart).
+ internal void UpdateTargets(IReadOnlyList canonicalTargets)
+ {
+ _canonicalTargets = canonicalTargets ?? new string[0];
+ }
+
+ /// Bind 127.0.0.1:0 and start accepting. The assigned port is available after.
+ internal void Start()
+ {
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ _listener.Start();
+ Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
+ _ = Task.Run(AcceptLoopAsync);
+ }
+
+ internal void Stop()
+ {
+ _stopping = true;
+ try
+ {
+ _listener?.Stop();
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port} stopped with: {exception.Message}");
+ }
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ var listener = _listener;
+ while (!_stopping)
+ {
+ TcpClient client;
+ try
+ {
+ client = await listener.AcceptTcpClientAsync();
+ }
+ catch (Exception)
+ {
+ // Stop() disposes the accept socket: expected teardown, not a fault.
+ if (!_stopping)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port} accept failed.");
+ }
+
+ return;
+ }
+
+ _ = Task.Run(() => ServeAsync(client));
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client)
+ {
+ using (client)
+ {
+ try
+ {
+ client.NoDelay = true;
+ using (var stream = client.GetStream())
+ {
+ await ProxyForwardConnection.HandleAsync(
+ stream,
+ stream,
+ _canonicalTargets,
+ _fetch,
+ message => EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port}: {message}"));
+ }
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy connection on port {Port} aborted: {exception.Message}");
+ }
+ }
+ }
+ }
+}
diff --git a/src/WebContent/Client/WebContentSettings.cs b/src/WebContent/Client/WebContentSettings.cs
index 73757ad..e41060e 100644
--- a/src/WebContent/Client/WebContentSettings.cs
+++ b/src/WebContent/Client/WebContentSettings.cs
@@ -200,6 +200,12 @@ public bool EnableDevTools
public bool ProxyEnabled => WebContentConfiguration.GetBoolean(GetProperty, false, nameof(ProxyEnabled));
+ ///
+ /// Raw canonical comma-separated proxy targets property (#52 seeding). The listener
+ /// consumes this through .
+ ///
+ public string ProxyTargetsProperty => WebContentConfiguration.GetString(GetProperty, string.Empty, "ProxyTargets");
+
public string ProxyDisplay
{
get
diff --git a/src/WebContent/Client/WebContentViewModel.cs b/src/WebContent/Client/WebContentViewModel.cs
index 6b008f9..396900f 100644
--- a/src/WebContent/Client/WebContentViewModel.cs
+++ b/src/WebContent/Client/WebContentViewModel.cs
@@ -86,10 +86,41 @@ public BrowserInstance Browser
// current address equals home. Wait for the manager; the designer path is exempt.
if (_browser == null && (_viewItemManager != null || _designerMode))
{
+ // Ticket #54: a proxied workspace resolves a proxy profile from the admin-seeded
+ // properties; the browser manager then fronts its profile with a loopback
+ // forwarding listener. Standalone/disabled workspaces pass no profile.
+ var settings = ViewItemManager?.Settings;
+ ProxyWorkspaceProfile proxyProfile;
+ ProxyWorkspaceProfile.TryCreate(
+ settings != null && settings.ProxyEnabled,
+ settings?.ProxyTargetsProperty,
+ out proxyProfile);
+
_browser = BrowserManager.GetBrowser(
Id,
WebContentConfiguration.CreateNavigationUri(Home, Strings.HomeAddress),
- ViewItemManager?.Settings.IsolateBrowserInstances ?? false);
+ settings?.IsolateBrowserInstances ?? false,
+ proxyProfile);
+ }
+ else if (_browser != null && _viewItemManager != null)
+ {
+ // The admin toggled this workspace's proxy since the browser was created
+ // (background-retained browsers outlive view items): reconcile, and recreate
+ // the WebView when the routing itself changed (#54, "never direct").
+ var settings = _viewItemManager.Settings;
+ ProxyWorkspaceProfile proxyProfile;
+ ProxyWorkspaceProfile.TryCreate(
+ settings.ProxyEnabled,
+ settings.ProxyTargetsProperty,
+ out proxyProfile);
+
+ if (BrowserManager.ApplyProxyProfile(_browser, proxyProfile))
+ {
+ var address = _viewItemManager.Settings.ResolveRestoreAddress() ?? Home;
+ _browser.RecreateWebView(
+ WebContentConfiguration.CreateNavigationUri(address, Home),
+ settings.IsolateBrowserInstances);
+ }
}
return _browser;
diff --git a/src/WebContent/ProxyBrowserArguments.cs b/src/WebContent/ProxyBrowserArguments.cs
new file mode 100644
index 0000000..38b93ec
--- /dev/null
+++ b/src/WebContent/ProxyBrowserArguments.cs
@@ -0,0 +1,21 @@
+namespace WebContent
+{
+ ///
+ /// Builds the WebView2 additional-browser-arguments string that pins a proxied workspace's
+ /// browser profile to the loopback forwarding listener (#54). http and https are both routed
+ /// to the listener — https arrives as CONNECT and is refused there, so the browser never
+ /// falls back to a direct connection — and Chromium's implicit localhost bypass is disabled
+ /// so loopback targets follow the same allowlist rule.
+ ///
+ internal static class ProxyBrowserArguments
+ {
+ internal static string For(int port)
+ {
+ // Schemes not listed in --proxy-server fall back to DIRECT, so every scheme the
+ // browser can use against a web page is pinned to the listener explicitly. The
+ // arguments are space-split by WebView2, and every value here is space-free.
+ return $"--proxy-server=http=http://127.0.0.1:{port};https=http://127.0.0.1:{port};ws=http://127.0.0.1:{port};wss=http://127.0.0.1:{port} " +
+ "--proxy-bypass-list=<-loopback>";
+ }
+ }
+}
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
index 42ff40b..853f4aa 100644
--- a/src/WebContent/ProxyFetchBackgroundPlugin.cs
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -32,6 +32,7 @@ internal sealed class ProxyFetchBackgroundPlugin : BackgroundPlugin
private MessageCommunication _messageCommunication;
private object _requestFilterRegistration;
private SemaphoreSlim _fetchSlots;
+ private ProxyServerAllowlist _allowlist;
public override Guid Id => PluginId;
@@ -55,6 +56,13 @@ public override void Init()
OnProxyFetchRequest,
new CommunicationIdFilter(ProxyMessageIds.FetchRequestId));
+ // Ticket #54 server-side hardening: the fetcher only reaches origins some proxied
+ // workspace actually allowlists, so the server network cannot be driven at
+ // arbitrary targets by a crafted request.
+ _allowlist = new ProxyServerAllowlist(
+ ResolveWorkspacePropertySnapshots,
+ message => EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), message));
+
EnvironmentManager.Instance.Log(
false,
nameof(ProxyFetchBackgroundPlugin),
@@ -100,9 +108,19 @@ public override void Close()
///
/// Message-receiver thread: keep it non-blocking. The fetch runs on the thread pool and
/// the response stream is pushed back to the requester's endpoint FQID.
+ ///
+ /// The requester endpoint comes from Message.ExternalMessageSourceEndPoint, which the MIP
+ /// messaging layer fills on every received message with the sender's endpoint FQID (the
+ /// callback's source parameter only carries an explicitly passed sender identity, which
+ /// ProxyFetchClient does not pass). Replying to that endpoint unicasts the response frames
+ /// to the requesting client; replying with a null destination would filter-route them to
+ /// every client subscribed to the response message id - leaking page bodies into unrelated
+ /// hosts and letting one client's correlation ids swallow another's frames.
///
private object OnProxyFetchRequest(Message message, FQID destination, FQID source)
{
+ var requester = ResolveRequester(source, message);
+
var payload = message?.Data as ProxyFetchRequestPayload;
if (payload == null || string.IsNullOrEmpty(payload.CorrelationId) || string.IsNullOrEmpty(payload.Url))
{
@@ -114,7 +132,15 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
if (!Uri.TryCreate(payload.Url, UriKind.Absolute, out target) || !ProxyPolicy.IsHttpUri(target))
{
LogRefused(payload, "the url was not an absolute http(s) uri");
- TransmitFailure(payload.CorrelationId, source, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri.");
+ TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri.");
+ return null;
+ }
+
+ string allowReason = null;
+ if (_allowlist == null || !_allowlist.IsAllowed(target, out allowReason))
+ {
+ LogRefused(payload, allowReason);
+ TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.NotAllowed, allowReason);
return null;
}
@@ -122,7 +148,7 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
if (communication == null)
{
LogRefused(payload, "the fetcher is not connected to the Event Server");
- TransmitFailure(payload.CorrelationId, source, ProxyFetchErrorCodes.Error, "The proxy fetcher is not ready.");
+ TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.Error, "The proxy fetcher is not ready.");
return null;
}
@@ -137,13 +163,13 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
if (!await slots.WaitAsync(FetchSlotWait))
{
LogRefused(payload, "the fetcher queue was saturated");
- TransmitFailure(payload.CorrelationId, source, ProxyFetchErrorCodes.Error, "The proxy fetcher is saturated; try again later.");
+ TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.Error, "The proxy fetcher is saturated; try again later.");
return;
}
try
{
- await ServeFetchAsync(communication, payload, target, source);
+ await ServeFetchAsync(communication, payload, target, requester);
}
finally
{
@@ -171,6 +197,18 @@ private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetc
$"Proxy fetch failed (code={result.ErrorCode}, target={ProxyPolicy.CanonicalOrigin(target)}): {result.ErrorMessage}");
}
+ if (requesterFqid == null)
+ {
+ // Nowhere to answer: broadcasting fetched page bodies to every messaging
+ // client is a leak, not a fallback. The requester's own deadline closes its
+ // exchange.
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Proxy fetch response for {payload.CorrelationId} not sent: the request carried no requester endpoint.");
+ return;
+ }
+
foreach (var frame in ProxyFetchFramer.Frame(payload.CorrelationId, result, limits.ChunkSizeBytes))
{
communication.TransmitMessage(new Message(ProxyMessageIds.FetchResponseId, frame), requesterFqid, null, null);
@@ -194,9 +232,54 @@ private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetc
}
}
+ ///
+ /// The endpoint to answer a request on. The explicit source FQID (rarely sent) wins; the
+ /// platform-filled ExternalMessageSourceEndPoint is the normal answer path. When neither
+ /// exists there is no unicast route, and responses must not fall back to a broadcast.
+ ///
+ private static FQID ResolveRequester(FQID source, Message message)
+ {
+ var requester = source ?? message?.ExternalMessageSourceEndPoint;
+ if (requester == null)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ "Proxy fetch request carried no requester endpoint; any response for it will be logged rather than sent.");
+ }
+
+ return requester;
+ }
+
+ ///
+ /// Snapshot of every workspace item's property dictionary for the allowlist union. The
+ /// same Configuration lookup the Smart Client workspace provisioning uses; if it fails,
+ /// the allowlist keeps its last-known-good gate (and stays closed before its first success).
+ ///
+ private static System.Collections.Generic.IEnumerable> ResolveWorkspacePropertySnapshots()
+ {
+ if (!Admin.WorkspaceItemManager.TryGetWorkspaces(out var workspaces, out var error))
+ {
+ throw error ?? new InvalidOperationException("Workspace configuration lookup failed.");
+ }
+
+ var snapshots = new List>();
+ foreach (var workspace in workspaces)
+ {
+ if (workspace != null && workspace.Properties != null)
+ {
+ snapshots.Add(workspace.Properties);
+ }
+ }
+
+ return snapshots;
+ }
+
private void TransmitFailure(string correlationId, FQID requester, string errorCode, string message)
{
var communication = _messageCommunication;
+ // A null requester is never broadcast: the condition is already logged once by
+ // ResolveRequester, and the client-side deadline closes the exchange.
if (communication == null || requester == null)
{
return;
diff --git a/src/WebContent/ProxyFetchClient.cs b/src/WebContent/ProxyFetchClient.cs
index dfa9daa..829cd3e 100644
--- a/src/WebContent/ProxyFetchClient.cs
+++ b/src/WebContent/ProxyFetchClient.cs
@@ -110,6 +110,10 @@ internal Task FetchAsync(string url, TimeSpan? deadline = null
try
{
+ // destinationEndPoint null: the Event Server filter-routes this to the proxy
+ // fetcher plugin. The source argument stays null because the messaging layer
+ // stamps every transmit with the sender's endpoint FQID (ExternalMessageSource-
+ // EndPoint on the receiver), which is what the fetcher answers back to.
communication.TransmitMessage(
new Message(
ProxyMessageIds.FetchRequestId,
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index d91663e..b1f8f24 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -56,6 +56,12 @@ internal static class ProxyFetchErrorCodes
/// Any other transport-level failure.
internal const string Error = "Error";
+
+ ///
+ /// Ticket #54 server-side hardening: the fetched origin is not in the proxy allowlist of
+ /// any proxied workspace, so the Event Server refuses to reach it.
+ ///
+ internal const string NotAllowed = "NotAllowed";
}
///
diff --git a/src/WebContent/ProxyForwardCodec.cs b/src/WebContent/ProxyForwardCodec.cs
new file mode 100644
index 0000000..9d5279c
--- /dev/null
+++ b/src/WebContent/ProxyForwardCodec.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Text;
+
+namespace WebContent
+{
+ ///
+ /// One proxied request the browser sent to the loopback forwarding proxy (#54), as parsed
+ /// from the absolute-form request line plus headers. Bodies are never read: the ES fetcher
+ /// is GET-only, so any method with a body is refused before the body arrives.
+ ///
+ internal sealed class ProxyForwardedRequest
+ {
+ internal string Method;
+
+ /// The request-target exactly as it arrived; CONNECT tunnels carry an authority here.
+ internal string RawTarget;
+
+ /// Absolute http(s) uri for forwarded requests; null for CONNECT tunnels.
+ internal Uri Uri;
+
+ /// Request headers, first value per name, original casing preserved.
+ internal Dictionary Headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Pure HTTP codec for the loopback forwarding proxy (#54): parses the absolute-form request
+ /// head Chromium sends to an HTTP proxy, renders a reassembled
+ /// back onto the socket as a forwarded response, and renders locally-generated refusal
+ /// responses. The socket plumbing lives in LoopbackProxyListener ; this class never
+ /// touches the network so every decision is unit-testable.
+ ///
+ internal static class ProxyForwardCodec
+ {
+ internal static bool TryParseRequestHead(string head, out ProxyForwardedRequest request, out string rejectReason)
+ {
+ request = null;
+ rejectReason = null;
+
+ if (string.IsNullOrEmpty(head))
+ {
+ rejectReason = "The proxy received an empty request head.";
+ return false;
+ }
+
+ var lines = head.Replace("\n", "\r\n").Split(new[] { "\r\n" }, StringSplitOptions.None);
+ var requestLine = lines[0].Split(new[] { ' ' }, 3);
+ if (requestLine.Length != 3 || !requestLine[2].StartsWith("HTTP/1", StringComparison.Ordinal))
+ {
+ rejectReason = "The proxy request line was malformed.";
+ return false;
+ }
+
+ var parsed = new ProxyForwardedRequest
+ {
+ Method = requestLine[0].ToUpperInvariant(),
+ RawTarget = requestLine[1],
+ };
+
+ // CONNECT carries an authority ("host:port"), not a uri. Parse it through so the
+ // decision layer can answer a refused tunnel (502) instead of a malformed-request 400.
+ Uri uri = null;
+ if (parsed.Method != "CONNECT" && !Uri.TryCreate(requestLine[1], UriKind.Absolute, out uri))
+ {
+ rejectReason = $"The proxy request target '{requestLine[1]}' was not an absolute uri.";
+ return false;
+ }
+
+ if (uri != null && !ProxyPolicy.IsHttpUri(uri))
+ {
+ rejectReason = $"The proxy request target '{requestLine[1]}' uses the unsupported scheme '{uri.Scheme}'.";
+ return false;
+ }
+
+ parsed.Uri = uri;
+
+ for (var line = 1; line < lines.Length; line++)
+ {
+ if (lines[line].Length == 0)
+ {
+ break;
+ }
+
+ var colon = lines[line].IndexOf(':');
+ if (colon <= 0)
+ {
+ continue;
+ }
+
+ var name = lines[line].Substring(0, colon).Trim();
+ var value = lines[line].Substring(colon + 1).Trim();
+ if (!parsed.Headers.ContainsKey(name))
+ {
+ parsed.Headers[name] = value;
+ }
+ }
+
+ request = parsed;
+ return true;
+ }
+
+ ///
+ /// Headers that describe the fetcher's own connection (or body framing the framer already
+ /// replaced), never the browser's — they must not be relayed onto the loopback socket.
+ /// Content-Length is in the set because the proxy restates it from the reassembled body.
+ ///
+ private static readonly HashSet RelayBlockedHeaders =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Connection",
+ "Keep-Alive",
+ "Proxy-Authenticate",
+ "Proxy-Authorization",
+ "Proxy-Connection",
+ "TE",
+ "Trailer",
+ "Transfer-Encoding",
+ "Upgrade",
+ "Content-Length",
+ };
+
+ ///
+ /// Render a reassembled fetch outcome (AC3: status code and headers intact) as an HTTP/1.1
+ /// response for the browser connection. Body framing is the proxy's own Content-Length;
+ /// the connection closes after the response.
+ ///
+ internal static byte[] RenderForwardedResponse(ProxyFetchResult result)
+ {
+ var body = result.Body ?? new byte[0];
+ var head = new StringBuilder();
+ head.Append($"HTTP/1.1 {result.StatusCode} {result.StatusText ?? "OK"}\r\n");
+
+ foreach (var header in result.Headers)
+ {
+ if (header == null || string.IsNullOrEmpty(header.Name) || RelayBlockedHeaders.Contains(header.Name))
+ {
+ continue;
+ }
+
+ head.Append($"{header.Name}: {header.Value}\r\n");
+ }
+
+ head.Append($"Content-Length: {body.Length}\r\n");
+ head.Append("Connection: close\r\n");
+ head.Append("\r\n");
+
+ return Combine(Encoding.UTF8.GetBytes(head.ToString()), body);
+ }
+
+ ///
+ /// Render a locally-generated refusal page (400/403/405/502/504) the browser can display,
+ /// explaining the proxy's decision.
+ ///
+ internal static byte[] RenderErrorResponse(int statusCode, string reasonPhrase, string message)
+ {
+ var safeMessage = WebUtility.HtmlEncode(message ?? string.Empty);
+ var bodyBytes = Encoding.UTF8.GetBytes(
+ $"Web Content proxy: {statusCode} " +
+ $"Web Content proxy: {reasonPhrase} {safeMessage}
");
+
+ var head =
+ $"HTTP/1.1 {statusCode} {reasonPhrase}\r\n" +
+ "Content-Type: text/html; charset=utf-8\r\n" +
+ $"Content-Length: {bodyBytes.Length}\r\n" +
+ "Connection: close\r\n" +
+ "\r\n";
+
+ return Combine(System.Text.Encoding.UTF8.GetBytes(head), bodyBytes);
+ }
+
+ private static byte[] Combine(byte[] head, byte[] body)
+ {
+ var combined = new byte[head.Length + body.Length];
+ Buffer.BlockCopy(head, 0, combined, 0, head.Length);
+ Buffer.BlockCopy(body, 0, combined, head.Length, body.Length);
+ return combined;
+ }
+ }
+}
diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs
new file mode 100644
index 0000000..e1bc7e9
--- /dev/null
+++ b/src/WebContent/ProxyForwardConnection.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace WebContent
+{
+ ///
+ /// One proxied browser connection handled end to end (#54): read the request head from the
+ /// stream, run the decision table ( ), fetch allowlisted GETs
+ /// through the caller-supplied fetch seam (MIP messaging to the Event Server in production,
+ /// a fake in tests), and render the answer back. Head reading is capped so a chatty or hostile
+ /// local client cannot pin unbounded memory on the receiver path.
+ ///
+ internal static class ProxyForwardConnection
+ {
+ /// Request heads past this size are refused outright: browser requests are far smaller.
+ internal const int MaxHeadBytes = 64 * 1024;
+
+ internal static async Task HandleAsync(
+ Stream input,
+ Stream output,
+ System.Collections.Generic.IEnumerable canonicalTargets,
+ Func> fetch,
+ Action log)
+ {
+ var head = await ReadRequestHeadAsync(input);
+ if (head == null)
+ {
+ // Chromium pre-opens sockets speculatively and may never write to them: an empty
+ // connection is closed quietly, with no response.
+ return;
+ }
+
+ var disposition = ProxyForwardPolicy.Decide(canonicalTargets, head, out var request, out var rejectReason);
+ switch (disposition)
+ {
+ case ProxyForwardDisposition.BadRequest:
+ log(rejectReason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", rejectReason));
+ return;
+
+ case ProxyForwardDisposition.OriginNotAllowed:
+ log(rejectReason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(403, "Forbidden", rejectReason));
+ return;
+
+ case ProxyForwardDisposition.MethodNotAllowed:
+ log(rejectReason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(405, "Method Not Allowed", rejectReason + " The workspace proxy only fetches GET."));
+ return;
+
+ case ProxyForwardDisposition.ConnectRefused:
+ log(rejectReason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", rejectReason + " The workspace proxy supports http(s) allowlisted targets fetched by the VMS server; https pages need a CONNECT tunnel this proxy does not provide."));
+ return;
+ }
+
+ ProxyFetchResult result;
+ try
+ {
+ result = await fetch(request.Uri.AbsoluteUri);
+ }
+ catch (Exception exception)
+ {
+ log($"Proxy fetch for {request.Uri} threw: {exception.Message}");
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", $"The VMS proxy fetcher failed: {exception.Message}"));
+ return;
+ }
+
+ if (result != null && result.Success)
+ {
+ await WriteAsync(output, ProxyForwardCodec.RenderForwardedResponse(result));
+ return;
+ }
+
+ var message = result == null ? "The VMS proxy fetcher returned no outcome." : result.ErrorMessage;
+ if (result != null && result.ErrorCode == ProxyFetchErrorCodes.Timeout)
+ {
+ log($"Proxy fetch for {request.Uri} timed out: {message}");
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(504, "Gateway Timeout", message));
+ return;
+ }
+
+ log($"Proxy fetch for {request.Uri} failed (code={result?.ErrorCode}): {message}");
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", message));
+ }
+
+ private static async Task ReadRequestHeadAsync(Stream input)
+ {
+ var buffer = new byte[8 * 1024];
+ var head = new MemoryStream();
+ while (true)
+ {
+ var read = await input.ReadAsync(buffer, 0, buffer.Length);
+ if (read <= 0)
+ {
+ break;
+ }
+
+ head.Write(buffer, 0, read);
+ if (ContainsHeadTerminator(head.GetBuffer(), 0, (int)head.Length))
+ {
+ break;
+ }
+
+ if (head.Length > MaxHeadBytes)
+ {
+ return null;
+ }
+ }
+
+ if (head.Length == 0)
+ {
+ return null;
+ }
+
+ // Head bytes are cut at the header terminator; request bodies (which the GET-only
+ // proxy never forwards) are never read. Latin-1 keeps byte values faithful per RFC 7230.
+ var bytes = head.ToArray();
+ var terminator = IndexOfHeadTerminator(bytes, 0, bytes.Length);
+ var textLength = terminator >= 0 ? terminator : bytes.Length;
+ return Encoding.GetEncoding("ISO-8859-1").GetString(bytes, 0, textLength);
+ }
+
+ private static bool ContainsHeadTerminator(byte[] bytes, int offset, int length)
+ {
+ return IndexOfHeadTerminator(bytes, offset, length) >= 0;
+ }
+
+ private static int IndexOfHeadTerminator(byte[] bytes, int offset, int length)
+ {
+ // CRLFCRLF, tolerating bare-LF clients, searched only against the newly appended range.
+ for (var i = offset; i + 1 < length; i++)
+ {
+ if (bytes[i] == '\n' && bytes[i + 1] == '\n')
+ {
+ return i;
+ }
+
+ if (i + 3 < length &&
+ bytes[i] == '\r' && bytes[i + 1] == '\n' && bytes[i + 2] == '\r' && bytes[i + 3] == '\n')
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private static async Task WriteAsync(Stream output, byte[] response)
+ {
+ await output.WriteAsync(response, 0, response.Length);
+ await output.FlushAsync();
+ }
+ }
+}
diff --git a/src/WebContent/ProxyForwardPolicy.cs b/src/WebContent/ProxyForwardPolicy.cs
new file mode 100644
index 0000000..8b0d054
--- /dev/null
+++ b/src/WebContent/ProxyForwardPolicy.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+
+namespace WebContent
+{
+ /// What the loopback forwarding proxy does with one parsed browser request (#54).
+ internal enum ProxyForwardDisposition
+ {
+ /// Forward the request to the Event Server fetcher and relay the answer.
+ Forward,
+
+ /// Malformed proxy request — 400.
+ BadRequest,
+
+ /// Origin not in the workspace allowlist (or address-bar navigation to it) — 403.
+ OriginNotAllowed,
+
+ /// Method other than GET (the ES fetcher is GET-only) — 405.
+ MethodNotAllowed,
+
+ /// CONNECT tunnel (https through a CONNECT-less forwarder) — 502.
+ ConnectRefused,
+ }
+
+ ///
+ /// Pure decision layer for the loopback forwarding proxy (#54): every browser request that
+ /// reaches the listener — page document, subresources, address-bar navigation alike — goes
+ /// through the same table. Allowlisted GETs forward (via ProxyPolicy from the config ticket
+ /// #52); everything else is refused with an explicit status, never sent direct.
+ ///
+ internal static class ProxyForwardPolicy
+ {
+ internal static ProxyForwardDisposition Decide(
+ IEnumerable canonicalTargets,
+ string requestHead,
+ out ProxyForwardedRequest request,
+ out string rejectReason)
+ {
+ if (!ProxyForwardCodec.TryParseRequestHead(requestHead, out request, out rejectReason))
+ {
+ return ProxyForwardDisposition.BadRequest;
+ }
+
+ if (request.Method == "CONNECT")
+ {
+ rejectReason = $"The proxy does not open CONNECT tunnels; https routes like '{request.RawTarget}' cannot be relayed and are never taken direct.";
+ return ProxyForwardDisposition.ConnectRefused;
+ }
+
+ if (request.Method != "GET")
+ {
+ rejectReason = $"The proxy fetcher is GET-only; '{request.Method}' to '{request.RawTarget}' is refused.";
+ return ProxyForwardDisposition.MethodNotAllowed;
+ }
+
+ if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, request.Uri))
+ {
+ rejectReason = $"The request origin '{ProxyPolicy.CanonicalOrigin(request.Uri)}' is not in the workspace proxy allowlist.";
+ return ProxyForwardDisposition.OriginNotAllowed;
+ }
+
+ return ProxyForwardDisposition.Forward;
+ }
+ }
+}
diff --git a/src/WebContent/ProxyServerAllowlist.cs b/src/WebContent/ProxyServerAllowlist.cs
new file mode 100644
index 0000000..339f592
--- /dev/null
+++ b/src/WebContent/ProxyServerAllowlist.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+
+namespace WebContent
+{
+ ///
+ /// Server-side hardening for the Event Server fetcher (#54): a fetch is only served when its
+ /// origin appears in the union of every proxied workspace's admin allowlist, so the fetcher
+ /// can no longer be driven at arbitrary targets of the server network. The workspace snapshot
+ /// is re-resolved through a caller-supplied seam (the MIP configuration API in production)
+ /// and kept across transient resolution failures; before the first successful resolve the
+ /// gate is closed.
+ ///
+ internal sealed class ProxyServerAllowlist
+ {
+ private static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(60);
+
+ private readonly Func>> _snapshotResolver;
+ private readonly Action _log;
+ private readonly object _sync = new object();
+ private HashSet _origins; // null until the first successful resolve
+ private DateTime _nextRefreshUtc = DateTime.MinValue;
+
+ internal ProxyServerAllowlist(Func>> snapshotResolver, Action log)
+ {
+ _snapshotResolver = snapshotResolver ?? throw new ArgumentNullException(nameof(snapshotResolver));
+ _log = log ?? throw new ArgumentNullException(nameof(log));
+ }
+
+ ///
+ /// True when the target origin is allowlisted by at least one proxied workspace. Refuses
+ /// with an actionable reason when the gate is closed or the origin misses every list.
+ ///
+ internal bool IsAllowed(Uri target, out string refuseReason)
+ {
+ EnsureCurrent();
+
+ lock (_sync)
+ {
+ if (_origins == null)
+ {
+ refuseReason = "The proxy fetcher has not been able to read the Configuration of proxied workspaces, so its allowlist gate is closed.";
+ return false;
+ }
+
+ var origin = ProxyPolicy.CanonicalOrigin(target);
+ if (_origins.Contains(origin))
+ {
+ refuseReason = null;
+ return true;
+ }
+
+ refuseReason = $"The origin '{origin}' is not in the proxy allowlist of any proxied workspace.";
+ return false;
+ }
+ }
+
+ private void EnsureCurrent()
+ {
+ var now = DateTime.UtcNow;
+ lock (_sync)
+ {
+ if (now < _nextRefreshUtc)
+ {
+ return;
+ }
+
+ _nextRefreshUtc = now.Add(DefaultRefreshInterval);
+ }
+
+ HashSet resolved;
+ try
+ {
+ resolved = BuildUnion(_snapshotResolver());
+ }
+ catch (Exception exception)
+ {
+ // Transient config-service failure: keep serving the last known allowlist rather
+ // than flapping every proxied page. Never resolved at all stays closed.
+ _log($"Proxy allowlist refresh failed: {exception.Message}");
+ return;
+ }
+
+ lock (_sync)
+ {
+ _origins = resolved;
+ }
+ }
+
+ private static HashSet BuildUnion(IEnumerable> workspaceProperties)
+ {
+ var origins = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (workspaceProperties == null)
+ {
+ return origins;
+ }
+
+ foreach (var properties in workspaceProperties)
+ {
+ var config = WorkspaceConfiguration.FromProperties(properties);
+ var settings = config?.GetFirstWebContentSettings();
+ if (settings == null || !settings.ProxyEnabled || settings.ProxyTargets == null)
+ {
+ continue;
+ }
+
+ foreach (var target in settings.ProxyTargets)
+ {
+ if (target != null &&
+ ProxyPolicy.TryNormalizeTargetParts(target.Scheme, target.Host, target.Port, out var origin))
+ {
+ origins.Add(origin);
+ }
+ }
+ }
+
+ return origins;
+ }
+ }
+}
diff --git a/src/WebContent/ProxyWorkspaceProfile.cs b/src/WebContent/ProxyWorkspaceProfile.cs
new file mode 100644
index 0000000..aefda6f
--- /dev/null
+++ b/src/WebContent/ProxyWorkspaceProfile.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+
+namespace WebContent
+{
+ ///
+ /// Per-view-item proxy state for browser-profile creation (#54): the admin-owned ProxyEnabled
+ /// toggle plus the canonical ProxyTargets property the #52 seeding path publishes to the view
+ /// item. An enabled proxy always produces a profile — with no usable targets the listener
+ /// still fronts the browser and refuses everything, which is the "never direct" posture.
+ ///
+ internal sealed class ProxyWorkspaceProfile
+ {
+ /// Canonical origins the loopback listener may forward to; may be empty.
+ internal IReadOnlyList CanonicalTargets { get; private set; }
+
+ internal static bool TryCreate(bool proxyEnabled, string canonicalTargetsProperty, out ProxyWorkspaceProfile profile)
+ {
+ profile = null;
+ if (!proxyEnabled)
+ {
+ return false;
+ }
+
+ var origins = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in (canonicalTargetsProperty ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (ProxyPolicy.TryNormalizeTarget(entry, out var normalized) && seen.Add(normalized))
+ {
+ origins.Add(normalized);
+ }
+ }
+
+ // An enabled proxy always fronts the browser: an empty usable list refuses everything,
+ // which keeps the "never direct" promise for corrupt or half-configured definitions.
+ profile = new ProxyWorkspaceProfile { CanonicalTargets = origins };
+ return true;
+ }
+ }
+}
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index 113c67b..f9e1c0b 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -86,6 +86,7 @@
+
True
@@ -110,6 +111,12 @@
+
+
+
+
+
+
diff --git a/src/WebContent/WebContentPluginDefinition.cs b/src/WebContent/WebContentPluginDefinition.cs
index 6a988af..68c2294 100644
--- a/src/WebContent/WebContentPluginDefinition.cs
+++ b/src/WebContent/WebContentPluginDefinition.cs
@@ -34,6 +34,7 @@ public class WebContentPluginDefinition : PluginDefinition
private int _emptyWorkspaceLoadCount;
private bool _workspaceLoadSucceeded;
private Timer _workspaceRetryTimer;
+ private DateTime _nextProxyChannelRetryUtc = DateTime.MinValue;
public override Guid Id => PluginId;
@@ -135,10 +136,13 @@ public override void Init()
case EnvironmentType.Administration:
// Management Client harness for the proxy-fetch exchange (#53): lets the admin
// config page fire a test fetch from the MC across to the Event Server.
- ProxyFetchClient.Active = new ProxyFetchClient();
- ProxyFetchClient.Active.Setup(EnvironmentManager.Instance.MasterSite == null ? null : EnvironmentManager.Instance.MasterSite.ServerId);
+ ProvisionProxyFetchClient();
break;
case EnvironmentType.SmartClient:
+ // Ticket #54: the Smart Client is the requester half of the proxy-fetch
+ // exchange - the loopback forwarding proxy in each proxied workspace's browser
+ // profile pushes allowlisted traffic to the Event Server fetcher through it.
+ ProvisionProxyFetchClient();
ViewItemPlugins.Add(new WebContentViewItemPlugin());
RegisterWorkspaceRecoveryReceivers();
StartWorkspaceRetryTimer();
@@ -161,6 +165,7 @@ public override async void Close()
{
StopWorkspaceRetryTimer();
UnregisterWorkspaceRecoveryReceivers();
+ BrowserManager.StopAllProxyListeners();
var proxyHarness = ProxyFetchClient.Active;
if (proxyHarness != null)
@@ -193,6 +198,47 @@ public override async void Close()
}
}
+ private static void ProvisionProxyFetchClient()
+ {
+ // Replace-then-close: the fetch seam never sees a null Active window, and the
+ // previous client's response filter is detached instead of leaking.
+ var previous = ProxyFetchClient.Active;
+ var client = new ProxyFetchClient();
+ ProxyFetchClient.Active = client;
+ client.Setup(EnvironmentManager.Instance.MasterSite == null ? null : EnvironmentManager.Instance.MasterSite.ServerId);
+ if (previous != null && !ReferenceEquals(previous, client))
+ {
+ previous.Close();
+ }
+ }
+
+ ///
+ /// Smart Client (#54): the proxy-fetch channel may not be establishable during Init while
+ /// the master site is still connecting. Re-provision it, throttled, once workspaces load.
+ ///
+ private void EnsureProxyFetchChannelReady()
+ {
+ if (EnvironmentManager.Instance.EnvironmentType != EnvironmentType.SmartClient)
+ {
+ return;
+ }
+
+ var active = ProxyFetchClient.Active;
+ if (active != null && active.IsReady)
+ {
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ if (now < _nextProxyChannelRetryUtc)
+ {
+ return;
+ }
+
+ _nextProxyChannelRetryUtc = now.AddSeconds(30);
+ ProvisionProxyFetchClient();
+ }
+
private void RegisterWorkspaceRecoveryReceivers()
{
_workspaceMessageRegistrations.Add(EnvironmentManager.Instance.RegisterReceiver(
@@ -312,6 +358,10 @@ private void EnsureWorkspacesLoaded(bool force, string reason)
_workspaceLoadSucceeded = true;
_nextWorkspaceRetryUtc = DateTime.MinValue;
}
+
+ // Workspaces loaded: give the Smart Client's proxy-fetch channel (which Init may not
+ // have been able to establish before login completed) another chance (#54).
+ EnsureProxyFetchChannelReady();
}
private void LogWorkspaceLoadFailure(string reason, Exception error, TimeSpan retryDelay)
diff --git a/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
new file mode 100644
index 0000000..4120c11
--- /dev/null
+++ b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
@@ -0,0 +1,107 @@
+using FlaUI.Core.AutomationElements;
+using WebContent.UxTests.Harness;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace WebContent.UxTests;
+
+///
+/// Diagnostic probe (#54): launches and logs in a Management Client, opens the workspace
+/// definition page's "Test proxy fetch" row, and captures the actual proxy-fetch outcomes
+/// (results land in the artifacts directory, proxy-probe-*.txt / .png). Case 1 exercises the
+/// server-side allowlist refusal round-trip, case 2 the allowlisted chunked success round-trip.
+///
+public class McProxyFetchProbeTests
+{
+ private const string WorkspaceName = "Demo Workspace";
+
+ private readonly ITestOutputHelper _output;
+
+ public McProxyFetchProbeTests(ITestOutputHelper output) => _output = output;
+
+ [Fact]
+ [Trait("Category", "probe")]
+ public void ProbeProxyTestFetchOutcomes()
+ {
+ var config = UxConfig.Load();
+ Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-proxy-probe"));
+ _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}");
+
+ ProcessKiller.KillClients("VideoOS.Administration");
+ var session = ClientSession.Launch(config.AdminClientExe);
+ var mcLogin = LoginFlow.WaitForLoginWindow(session);
+ LoginFlow.Login(session, mcLogin, config);
+ LoginFlow.AcceptCertificateTrust(session);
+ var mcMain = LoginFlow.WaitForAdminMain(session);
+ ManagementClientUi.CloseStrayLoginDialogs(session);
+
+ ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, WorkspaceName);
+ ManagementClientUi.SelectTreeItem(mcMain, WorkspaceName);
+
+ var urlBox = ManagementClientUi.ExpectControl(mcMain, "txtProxyTestUrl", 30);
+ Artifacts.Window(mcMain, "proxy-probe-page");
+
+ var chk = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("chkProxyEnabled"));
+ string proxyToggle = chk != null && chk.Patterns.Toggle.IsSupported
+ ? chk.Patterns.Toggle.Pattern.ToggleState.Value.ToString()
+ : "unknown";
+ var targets = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("txtProxyTargets"));
+ var targetsText = targets == null ? "(no txtProxyTargets control)" : ReadValue(targets);
+
+ var log = new System.Text.StringBuilder();
+ log.AppendLine($"workspace='{WorkspaceName}' proxyEnabledToggle={proxyToggle} targets={targetsText}");
+
+ // 1) Origin no proxied workspace allowlists: proves the request reaches the Event Server
+ // fetcher, the server-side allowlist gate answers, and the answer travels back (a
+ // NotAllowed failure frame, not a client-side timeout).
+ log.AppendLine($"non-allowlisted http://10.255.255.1/ -> {RunFetch(mcMain, urlBox, "http://10.255.255.1/")}");
+
+ // 2) The workspace's allowlisted proxy target (the stored txtProxyTargets entry): proves
+ // the ES-side workspace config resolve succeeded and the chunked success path
+ // round-trips. The ES fetcher reaches https fine; only the SC browser's loopback leg
+ // is http-only.
+ log.AppendLine($"allowlisted https://joshooaj.com/ -> {RunFetch(mcMain, urlBox, "https://joshooaj.com/")}");
+
+ Artifacts.WriteText("proxy-probe-results.txt", log.ToString());
+ _output.WriteLine(log.ToString());
+
+ var result = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("lblProxyTestResult"));
+ if (result != null)
+ {
+ Artifacts.WindowCrop(mcMain, "proxy-probe-result", urlBox.BoundingRectangle, result.BoundingRectangle);
+ }
+ }
+
+ private string RunFetch(Window mcMain, AutomationElement urlBox, string url)
+ {
+ ManagementClientUi.SetValue(mcMain, "txtProxyTestUrl", url);
+ var button = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("btnProxyTestFetch"))
+ ?? throw new InvalidOperationException("btnProxyTestFetch not found.");
+
+ var before = ResultText(mcMain);
+ try { button.Patterns.Invoke.Pattern.Invoke(); }
+ catch { button.Click(); }
+
+ var deadline = DateTime.UtcNow.AddSeconds(45);
+ while (DateTime.UtcNow < deadline)
+ {
+ var text = ResultText(mcMain);
+ if (!string.IsNullOrEmpty(text)
+ && text != before
+ && !text.StartsWith("Fetching through", StringComparison.OrdinalIgnoreCase))
+ return text;
+ Thread.Sleep(500);
+ }
+
+ return $"(timeout waiting for result; last text: '{ResultText(mcMain)}')";
+ }
+
+ private static string ResultText(Window mcMain) =>
+ mcMain.FindFirstDescendant(cf => cf.ByAutomationId("lblProxyTestResult"))?.Name ?? string.Empty;
+
+ private static string ReadValue(AutomationElement element)
+ {
+ try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; }
+ catch { return element.Name; }
+ }
+}
From 5a7a7c0aa0cd0a8350e029e2e9df744918c55fc5 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 09:06:04 -0700
Subject: [PATCH 006/187] fix(proxy): carry fetch payloads as JSON strings;
plugin types never cross the wire
Smart Client timed out every proxied page load while the Event Server answered
in ~70 ms. Root cause: the platform rehydrates ExternalMessage.Data via
"new DataContractSerializer(Type.GetType(DataClass))" (ExternalMessage.cs). In
the Smart Client host Type.GetType cannot see WebContent.dll, returns null, the
platform swallows the ArgumentNullException ("Value cannot be null", logged as
"ExtenalMessage.Deserialize ():Unable to deserialize class:...") and delivers
Data == null to the receive callback - a silent black hole. Management Client
and the Event Server resolve plugin types fine, which is why only Smart Client
failed. Proven with the trace-gated request-echo filter: even the client's own
transmitted FetchRequest echoed back with null Data.
Fix: ProxyFetchWireCodec serializes every wire payload to a plain JSON string
(System.String binds in every host); encode at all four transmits, decode at
both receives. Codec file registered in WebContent.csproj and the
WebContent.Tests.csproj Compile link.
Also in this change:
- ProxyFetchCorrelation.Feed returns whether the frame owned a pending
exchange (powers the unmatched-frame trace); unit-pinned.
- Trace-gated client diagnostics (WEBCONTENT_MIP_TRACE=1): not-recognized /
unmatched / request-echo logs; Close() detaches both filters.
- ScProxyFetchProbeTests: FlaUI Smart Client probe (Category=probe) with the
SC-1 negative control (WEBCONTENT_SC1_BLOCKED=1 logs a direct client-side
GET that must fail while the proxied page renders).
- McProxyFetchProbeTests case 2 moved to http://httpforever.com/.
- Verification matrix: ES-1 bullet 3 rewritten (JSON-string wire, no payload
whitelist rationale), SC-1 harness + pass record, new regression-table row
pinning JSON-string payloads.
Live proof (drop AF65D193...7FF9 on both machines): SC probe rendered
'httpforever' (title + 2103 body chars) with zero deserialize/timeout lines;
blocked-control run rendered identically while the same process's direct GET
was refused - traffic originates on the Event Server. MC-10 regression on the
codec build: NotAllowed 0.1s + HTTP 200 11513 bytes. Unit suite: 309 pass.
Refs #54
---
docs/verification-matrix.md | 36 +++--
.../ProxyFetchCorrelationTests.cs | 34 +++++
.../ProxyFetchReplyGuardTests.cs | 23 +++-
.../ProxyFetchWireCodecTests.cs | 96 +++++++++++++
src/WebContent.Tests/WebContent.Tests.csproj | 1 +
src/WebContent/ProxyFetchBackgroundPlugin.cs | 16 ++-
src/WebContent/ProxyFetchClient.cs | 98 ++++++++++++--
src/WebContent/ProxyFetchContract.cs | 8 +-
src/WebContent/ProxyFetchCorrelation.cs | 17 ++-
src/WebContent/ProxyFetchWireCodec.cs | 92 +++++++++++++
src/WebContent/WebContent.csproj | 1 +
.../McProxyFetchProbeTests.cs | 8 +-
.../ScProxyFetchProbeTests.cs | 126 ++++++++++++++++++
13 files changed, 514 insertions(+), 42 deletions(-)
create mode 100644 src/WebContent.Tests/ProxyFetchWireCodecTests.cs
create mode 100644 src/WebContent/ProxyFetchWireCodec.cs
create mode 100644 tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 401882f..577d405 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -105,11 +105,15 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp).
2. Expected marker in the Event Server's `Logs\MIP.log` (ProgramData for the Event Server):
`Proxy fetcher ready (concurrency=3, request id=WebContent.Proxy.FetchRequest)`. A startup
failure logs `Proxy fetcher failed to start: ...` instead.
-3. No `MessageCommunicationSettings.config` whitelist edit is needed: every payload type
- (`ProxyFetchRequestPayload`, `ProxyFetchResponsePayload`, `ProxyFetchHeader`) lives in the
- plugin's own namespace, which the messaging layer trusts by default. The escape hatch only
- becomes necessary if a payload ever references a foreign assembly type — then that type would
- have to be added to the config file next to the host executable.
+3. No `MessageCommunicationSettings.config` whitelist edit is needed — and since #54 no custom
+ payload type is ever bound by a host process: every wire payload is a plain JSON STRING
+ (`ProxyFetchWireCodec`). The platform rehydrates `ExternalMessage.Data` via
+ `new DataContractSerializer(Type.GetType(DataClass))`; Smart Client's plugin loading leaves
+ `WebContent.dll` invisible to that type resolution, `Type.GetType` returns null, the platform
+ logs `ExtenalMessage.Deserialize ():Unable to deserialize class:... , Exception:Value cannot
+ be null.` and hands the receiver `Data == null` — a silent black hole (MC and the Event Server
+ resolve plugin types fine, which is why only Smart Client failed). Never put plugin types on
+ the wire.
4. Response routing is unicast: the fetcher answers on `Message.ExternalMessageSourceEndPoint`,
which the messaging layer fills with the requester's endpoint FQID on every received message.
(The receive callback's `source` parameter is null for our requests — it only carries an
@@ -155,10 +159,11 @@ device or any http page — https is refused by design, see below).
one browser process is shared per user-data folder, and the `--proxy-server` argument must not
collide with other workspaces' WebViews.
3. Expected: the home page renders, and its images/CSS load — subresources go through the same
- listener. Then block the client machine's route to the target (`New-NetFirewallRule` block,
- or pull the client off that segment). Reload: the page MUST still render, while a direct
- `Invoke-WebRequest http://10.0.0.5:8080/` from the client machine now fails. That asymmetry
- is the proof the traffic originates on the Event Server machine.
+ listener. Then block the client machine's route to the target (`New-NetFirewallRule` block, a
+ hosts-file entry pointing the target at 127.0.0.1, or pull the client off that segment).
+ Reload: the page MUST still render, while a direct `Invoke-WebRequest http://10.0.0.5:8080/`
+ from the client machine now fails. That asymmetry is the proof the traffic originates on the
+ Event Server machine.
4. Address-bar navigation to a NON-allowlisted origin in the proxied workspace shows the proxy's
403 page ("not in the workspace proxy allowlist"), and no request leaves for it (the fetcher
is never asked). Navigation to an https:// origin shows the 502 CONNECT-refusal page — https
@@ -170,6 +175,18 @@ device or any http page — https is refused by design, see below).
nothing listening afterwards. (A mere close of the view tab keeps a background-retained
browser — and its listener — alive for re-attach; that is existing browser lifecycle behavior.)
+ Automatable harness: `tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs` (xunit
+ `Category=probe`) launches Smart Client, logs in, selects the proxied workspace, navigates the
+ allowlisted target, and writes `sc-proxy-probe-results.txt` (+ page screenshot) under
+ `%TEMP%\webcontent-sc-proxy-probe\`. For row 3's negative control, add the client-machine
+ hosts entry `127.0.0.1 # webcontent-sc-1-block` and run with
+ `WEBCONTENT_SC1_BLOCKED=1`: the probe then logs a direct client-side GET that MUST fail while
+ the rendered page still comes through the proxy.
+ Record (2026-10-31, drop `AF65D193…7FF9` on both machines, target `http://httpforever.com/`):
+ page rendered (`title='HTTP Forever - A reliably insecure connection'`, 2103 body chars);
+ blocked control rendered identically while the same process's direct GET failed with
+ `actively refused it` — server-origin proof satisfied.
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
@@ -188,6 +205,7 @@ device or any http page — https is refused by design, see below).
| Chunk framer or correlation reassembler losing frames/order so the client sees truncated bodies (#53) | `ProxyFetchFramerTests`, `ProxyFetchCorrelationTests`, `ProxyFetchTransportTests` |
| Proxied workspace silently reaching targets direct, or driving the ES fetcher at arbitrary origins (#54) | `ProxyForwardPolicyTests` (403/405/502 table), `ProxyBrowserArgumentsTests` (no scheme falls back to direct), `ProxyWorkspaceProfileTests` (enabled always proxies), `ProxyServerAllowlistTests` (ES-side union gate, closed before first resolve), SC-1 |
| Reassembled body framed with the target's stale Content-Length or hop-by-hop headers, corrupting the rendered page (#54) | `ProxyForwardCodecTests` (Content-Length restated, hop-by-hop dropped) |
+| Proxy-fetch payloads moved back from JSON strings to plugin-namespace types, silently null-ing `Message.Data` on hosts whose type resolution cannot see WebContent.dll (the Smart Client black hole) (#54) | `ProxyFetchWireCodecTests` (round-trip + foreign-content pins), `ProxyFetchReplyGuardTests.BothSidesCarryPayloadsAsJsonStringsNeverAsPluginTypes` (both transmit sides), ES-1 bullet 3, SC-1 |
## Scope notes (deliberate)
diff --git a/src/WebContent.Tests/ProxyFetchCorrelationTests.cs b/src/WebContent.Tests/ProxyFetchCorrelationTests.cs
index 5c7fb7b..fb8e36d 100644
--- a/src/WebContent.Tests/ProxyFetchCorrelationTests.cs
+++ b/src/WebContent.Tests/ProxyFetchCorrelationTests.cs
@@ -201,6 +201,40 @@ public async Task ChunkBytesUnderDeclaringTheBodyCompleteWithErrorNotTruncatedSu
Assert.Equal(ProxyFetchErrorCodes.Error, completed.ErrorCode);
}
+ [Fact]
+ public async Task FeedReturnsWhetherTheFrameOwnedAPendingExchange()
+ {
+ var correlation = new ProxyFetchCorrelation();
+ var completion = correlation.Begin("corr-owned", TimeSpan.FromSeconds(30));
+
+ // A frame addressed to a live exchange reports a match even when this frame itself
+ // is malformed (unknown kind) and the exchange stays open.
+ Assert.True(correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-owned",
+ Kind = "bogus-kind",
+ Sequence = -1,
+ }));
+
+ // Frames nobody owns - wrong id, no id - report no match (the trace hook leans on
+ // exactly this signal to name silent black holes).
+ Assert.False(correlation.Feed(new ProxyFetchResponsePayload { CorrelationId = "someone-else", Kind = ProxyFetchFrameKinds.Failure }));
+ Assert.False(correlation.Feed(new ProxyFetchResponsePayload { CorrelationId = "", Kind = ProxyFetchFrameKinds.Failure }));
+ Assert.False(correlation.Feed(null));
+
+ correlation.Feed(new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-owned",
+ Kind = ProxyFetchFrameKinds.Failure,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ });
+ var completed = await completion;
+ Assert.False(completed.Success);
+
+ // After completion the id is no longer pending: late duplicate frames report no match.
+ Assert.False(correlation.Feed(new ProxyFetchResponsePayload { CorrelationId = "corr-owned", Kind = ProxyFetchFrameKinds.Failure }));
+ }
+
[Fact]
public async Task UnusableBodyDeclarationInMetaFrameCompletesWithErrorWithoutThrowing()
{
diff --git a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
index 0e52a08..ae67f4a 100644
--- a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
+++ b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
@@ -49,11 +49,30 @@ public void ResponseTransmitsNeverFallBackToNullDestinationBroadcast()
// Success frames: unicast to the requester endpoint, and the frame loop is guarded so
// a request without a requester endpoint is logged, not broadcast.
Assert.Contains("if (requesterFqid == null)", fetcher);
- Assert.Contains("ProxyMessageIds.FetchResponseId, frame), requesterFqid, null, null", fetcher);
+ Assert.Contains("ProxyMessageIds.FetchResponseId, ProxyFetchWireCodec.ToWire(frame)), requesterFqid, null, null", fetcher);
// Failure frames: unicast to the requester as well (message, requester, null, null).
var collapsed = System.Text.RegularExpressions.Regex.Replace(fetcher, @"\s+", " ");
- Assert.Contains("ErrorMessage = message, }), requester, null, null);", collapsed);
+ Assert.Contains("ErrorMessage = message, })), requester, null, null);", collapsed);
+ }
+
+ [Fact]
+ public void BothSidesCarryPayloadsAsJsonStringsNeverAsPluginTypes()
+ {
+ // The platform rehydrates Data via Type.GetType(DataClass) and silently delivers
+ // null when the host cannot resolve the plugin type (Smart Client). Every transmit
+ // therefore goes through the wire codec, and every receive decodes from it.
+ var fetcher = ReadFetcher();
+ var client = File.ReadAllText(Path.Combine(
+ RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyFetchClient.cs"));
+
+ Assert.Contains("ProxyFetchWireCodec.RequestFromWire(message?.Data)", fetcher);
+ Assert.Contains("ProxyFetchWireCodec.ToWire(new ProxyFetchResponsePayload", fetcher);
+ Assert.DoesNotContain("message?.Data as ProxyFetchRequestPayload", fetcher);
+
+ Assert.Contains("ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload", client);
+ Assert.Contains("ProxyFetchWireCodec.FrameFromWire(message?.Data)", client);
+ Assert.DoesNotContain("message?.Data as ProxyFetchResponsePayload", client);
}
}
}
diff --git a/src/WebContent.Tests/ProxyFetchWireCodecTests.cs b/src/WebContent.Tests/ProxyFetchWireCodecTests.cs
new file mode 100644
index 0000000..88d6e5b
--- /dev/null
+++ b/src/WebContent.Tests/ProxyFetchWireCodecTests.cs
@@ -0,0 +1,96 @@
+using System;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// #54 live-host fix: proxy-fetch payloads travel as JSON strings, never as plugin-namespace
+ /// objects. The platform binds custom Data types with Type.GetType(DataClass) and hands the
+ /// receiver a null Data on hosts whose type resolution cannot see WebContent.dll (Smart
+ /// Client's black hole). The codec keeps every wire payload a plain string.
+ ///
+ public class ProxyFetchWireCodecTests
+ {
+ [Fact]
+ public void RequestRoundTripsThroughAString()
+ {
+ var wire = ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload
+ {
+ CorrelationId = "corr-1",
+ Url = "http://httpforever.com/",
+ });
+
+ Assert.IsType(wire);
+ Assert.Contains("corr-1", wire);
+
+ var back = ProxyFetchWireCodec.RequestFromWire(wire);
+ Assert.NotNull(back);
+ Assert.Equal("corr-1", back.CorrelationId);
+ Assert.Equal("http://httpforever.com/", back.Url);
+ }
+
+ [Fact]
+ public void ResponseFrameRoundTripsEveryFieldTheProtocolUses()
+ {
+ var frame = new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-2",
+ Kind = ProxyFetchFrameKinds.Response,
+ Sequence = -1,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new System.Collections.Generic.List
+ {
+ new ProxyFetchHeader { Name = "Content-Type", Value = "text/html" },
+ },
+ BodyLength = 11513,
+ ChunkCount = 1,
+ };
+
+ var back = ProxyFetchWireCodec.FrameFromWire(ProxyFetchWireCodec.ToWire(frame));
+ Assert.NotNull(back);
+ Assert.Equal("corr-2", back.CorrelationId);
+ Assert.Equal(ProxyFetchFrameKinds.Response, back.Kind);
+ Assert.Equal(200, back.StatusCode);
+ Assert.Equal(11513, back.BodyLength);
+ Assert.Equal(1, back.ChunkCount);
+ Assert.Single(back.Headers);
+ Assert.Equal("Content-Type", back.Headers[0].Name);
+ }
+
+ [Fact]
+ public void ChunkFramesRoundTripBase64Bodies()
+ {
+ var body = new byte[9000];
+ new Random(7).NextBytes(body);
+ var frame = new ProxyFetchResponsePayload
+ {
+ CorrelationId = "corr-3",
+ Kind = ProxyFetchFrameKinds.Chunk,
+ Sequence = 0,
+ IsLastChunk = true,
+ ContentBase64 = Convert.ToBase64String(body),
+ };
+
+ var back = ProxyFetchWireCodec.FrameFromWire(ProxyFetchWireCodec.ToWire(frame));
+ Assert.NotNull(back);
+ Assert.Equal(Convert.ToBase64String(body), back.ContentBase64);
+ Assert.True(back.IsLastChunk);
+ }
+
+ [Fact]
+ public void NonStringForeignAndGarbageWireContentDecodeToNull()
+ {
+ // Pre-codec senders (typed payload objects) and hostile content both decode to the
+ // "unusable frame" signal instead of throwing on the message-receiver thread.
+ Assert.Null(ProxyFetchWireCodec.RequestFromWire(null));
+ Assert.Null(ProxyFetchWireCodec.RequestFromWire(42));
+ Assert.Null(ProxyFetchWireCodec.RequestFromWire("{not json"));
+ Assert.Null(ProxyFetchWireCodec.RequestFromWire("{\"Bogus\":1}"));
+
+ Assert.Null(ProxyFetchWireCodec.FrameFromWire(null));
+ Assert.Null(ProxyFetchWireCodec.FrameFromWire(new ProxyFetchResponsePayload { CorrelationId = "typed-legacy" }));
+ Assert.Null(ProxyFetchWireCodec.FrameFromWire("garbage"));
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 1c5c89b..5517e6c 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -32,6 +32,7 @@
+
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
index 853f4aa..d4853f5 100644
--- a/src/WebContent/ProxyFetchBackgroundPlugin.cs
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -46,6 +46,14 @@ public override void Init()
try
{
+ // Same opt-in messaging trace as the client side: with WEBCONTENT_MIP_TRACE=1 on
+ // the Event Server machine, the platform logs every message this process's
+ // messaging client transmits and receives.
+ if (Environment.GetEnvironmentVariable("WEBCONTENT_MIP_TRACE") == "1")
+ {
+ EnvironmentManager.Instance.TraceMessageCommunication = true;
+ }
+
_serverId = EnvironmentManager.Instance.MasterSite.ServerId;
// Per the MIP messaging docs the Event Server plug-in may initialize the message
@@ -121,7 +129,7 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
{
var requester = ResolveRequester(source, message);
- var payload = message?.Data as ProxyFetchRequestPayload;
+ var payload = ProxyFetchWireCodec.RequestFromWire(message?.Data);
if (payload == null || string.IsNullOrEmpty(payload.CorrelationId) || string.IsNullOrEmpty(payload.Url))
{
EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Proxy fetch request carried no usable payload; refused.");
@@ -211,7 +219,7 @@ private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetc
foreach (var frame in ProxyFetchFramer.Frame(payload.CorrelationId, result, limits.ChunkSizeBytes))
{
- communication.TransmitMessage(new Message(ProxyMessageIds.FetchResponseId, frame), requesterFqid, null, null);
+ communication.TransmitMessage(new Message(ProxyMessageIds.FetchResponseId, ProxyFetchWireCodec.ToWire(frame)), requesterFqid, null, null);
}
}
catch (Exception exception)
@@ -288,14 +296,14 @@ private void TransmitFailure(string correlationId, FQID requester, string errorC
communication.TransmitMessage(
new Message(
ProxyMessageIds.FetchResponseId,
- new ProxyFetchResponsePayload
+ ProxyFetchWireCodec.ToWire(new ProxyFetchResponsePayload
{
CorrelationId = correlationId,
Kind = ProxyFetchFrameKinds.Failure,
Sequence = -1,
ErrorCode = errorCode,
ErrorMessage = message,
- }),
+ })),
requester,
null,
null);
diff --git a/src/WebContent/ProxyFetchClient.cs b/src/WebContent/ProxyFetchClient.cs
index 829cd3e..813294d 100644
--- a/src/WebContent/ProxyFetchClient.cs
+++ b/src/WebContent/ProxyFetchClient.cs
@@ -27,7 +27,9 @@ internal sealed class ProxyFetchClient
private ServerId _serverId;
private MessageCommunication _communication;
private object _responseFilterRegistration;
+ private object _echoFilterRegistration;
private ProxyFetchCorrelation _correlation;
+ private bool _trace;
internal bool IsReady => _communication != null;
@@ -45,6 +47,16 @@ internal void Setup(ServerId masterSite)
try
{
+ // Messaging black holes (a request that transmits but never gets answered) are
+ // invisible otherwise: the platform's own CommunicationClient traces every
+ // transmit/receive when this flag is on. Opt in with WEBCONTENT_MIP_TRACE=1.
+ if (Environment.GetEnvironmentVariable("WEBCONTENT_MIP_TRACE") == "1")
+ {
+ _trace = true;
+ EnvironmentManager.Instance.TraceMessageCommunication = true;
+ EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Messaging tracing enabled (WEBCONTENT_MIP_TRACE=1).");
+ }
+
MessageCommunicationManager.Start(masterSite);
_serverId = masterSite;
_communication = MessageCommunicationManager.Get(masterSite);
@@ -53,6 +65,17 @@ internal void Setup(ServerId masterSite)
OnProxyFetchResponse,
new CommunicationIdFilter(ProxyMessageIds.FetchResponseId));
+ // Diagnostic (trace only): our own requests are filter-routed back to the sender's
+ // endpoint queue. Inspecting that echo tests whether THIS process deserializes our
+ // payload types at all: an intact echo isolates payload stripping to the response
+ // leg, a null-Data echo isolates it to this process's message plumbing.
+ if (_trace)
+ {
+ _echoFilterRegistration = _communication.RegisterCommunicationFilter(
+ OnFetchRequestEcho,
+ new CommunicationIdFilter(ProxyMessageIds.FetchRequestId));
+ }
+
EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Proxy fetch harness ready.");
}
catch (Exception exception)
@@ -66,19 +89,28 @@ internal void Setup(ServerId masterSite)
internal void Close()
{
var communication = _communication;
- if (communication != null && _responseFilterRegistration != null)
+ if (communication != null)
{
- try
+ foreach (var registration in new[] { _responseFilterRegistration, _echoFilterRegistration })
{
- communication.UnRegisterCommunicationFilter(_responseFilterRegistration);
- }
- catch (Exception exception)
- {
- EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch response filter detach failed: {exception.Message}");
+ if (registration == null)
+ {
+ continue;
+ }
+
+ try
+ {
+ communication.UnRegisterCommunicationFilter(registration);
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch filter detach failed: {exception.Message}");
+ }
}
}
_responseFilterRegistration = null;
+ _echoFilterRegistration = null;
_communication = null;
_correlation = null;
@@ -114,14 +146,17 @@ internal Task FetchAsync(string url, TimeSpan? deadline = null
// fetcher plugin. The source argument stays null because the messaging layer
// stamps every transmit with the sender's endpoint FQID (ExternalMessageSource-
// EndPoint on the receiver), which is what the fetcher answers back to.
+ // Data is a JSON string, never the payload class: the platform binds custom
+ // payload types via Type.GetType and silently delivers null Data on hosts whose
+ // type resolution cannot see WebContent.dll (Smart Client). See ProxyFetchWireCodec.
communication.TransmitMessage(
new Message(
ProxyMessageIds.FetchRequestId,
- new ProxyFetchRequestPayload
+ ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload
{
CorrelationId = correlationId,
Url = target.AbsoluteUri,
- }),
+ })),
null,
null,
null);
@@ -139,10 +174,51 @@ internal Task FetchAsync(string url, TimeSpan? deadline = null
private object OnProxyFetchResponse(Message message, FQID destination, FQID source)
{
- var frame = message?.Data as ProxyFetchResponsePayload;
+ var frame = ProxyFetchWireCodec.FrameFromWire(message?.Data);
if (frame != null)
{
- _correlation?.Feed(frame);
+ var correlation = _correlation;
+ if (correlation != null && !correlation.Feed(frame) && _trace)
+ {
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyFetchClient),
+ $"Proxy fetch response frame unmatched: kind={frame.Kind} correlationId='{frame.CorrelationId}'.");
+ }
+
+ return null;
+ }
+
+ // A FetchResponse message we cannot decode is the classic silent black hole. With
+ // tracing on, name what actually arrived (null Data = the platform failed to bind the
+ // wire type; a non-string type = foreign or pre-codec-format sender).
+ if (_trace && message != null)
+ {
+ var actual = message.Data == null ? "null Data" : message.Data.GetType().FullName;
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyFetchClient),
+ $"Proxy fetch response frame not recognized: {actual} destination={destination} source={source}");
+ }
+
+ return null;
+ }
+
+ private object OnFetchRequestEcho(Message message, FQID destination, FQID source)
+ {
+ if (_trace)
+ {
+ var request = ProxyFetchWireCodec.RequestFromWire(message?.Data);
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyFetchClient),
+ "Proxy fetch request echo: " +
+ (message?.Data == null
+ ? "null Data"
+ : request != null
+ ? "payload intact, id=" + request.CorrelationId
+ : "undecodable content of type " + message.Data.GetType().FullName) +
+ $" destination={destination} source={source}");
}
return null;
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index b1f8f24..f4fe9bb 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -10,10 +10,10 @@ namespace WebContent
/// also registered via PluginDefinition.PluginDefinedMessageIds.
///
/// Payload whitelist: the Event Server discards messages whose Data object is outside the
- /// allowed type set, but "types from plug-in defined namespace are allowed", so every payload
- /// below lives in this plugin namespace and only references whitelist-safe primitives,
- /// List<T>, and plugin-namespace types. No MessageCommunicationSettings.config escape
- /// hatch is needed as long as that rule holds (docs/verification-matrix.md, ES rows).
+ /// allowed type set; since #54 the wire Data is always a plain JSON STRING (see
+ /// ProxyFetchWireCodec) - custom payload classes are never bound by host processes. The
+ /// platform rehydrates Data via Type.GetType(DataClass), which silently yields null on hosts
+ /// (Smart Client) whose type resolution cannot see WebContent.dll.
///
internal static class ProxyMessageIds
{
diff --git a/src/WebContent/ProxyFetchCorrelation.cs b/src/WebContent/ProxyFetchCorrelation.cs
index 170e891..deed069 100644
--- a/src/WebContent/ProxyFetchCorrelation.cs
+++ b/src/WebContent/ProxyFetchCorrelation.cs
@@ -64,20 +64,22 @@ internal Task Begin(string correlationId, TimeSpan timeout)
///
/// Route one response frame. Frames for correlation ids no longer pending (timed out or
- /// already completed) or with malformed content are ignored, not thrown.
+ /// already completed) or with malformed content are ignored, not thrown. Returns true when
+ /// the frame was addressed to a pending exchange (even if this frame itself was malformed
+ /// and the exchange stays open), false when no pending exchange owns the correlation id.
///
- internal void Feed(ProxyFetchResponsePayload frame)
+ internal bool Feed(ProxyFetchResponsePayload frame)
{
if (frame == null || string.IsNullOrEmpty(frame.CorrelationId))
{
- return;
+ return false;
}
lock (_sync)
{
if (!_pending.TryGetValue(frame.CorrelationId, out var pending) || pending.Completed)
{
- return;
+ return false;
}
if (frame.Kind == ProxyFetchFrameKinds.Failure)
@@ -89,7 +91,7 @@ internal void Feed(ProxyFetchResponsePayload frame)
ErrorCode = string.IsNullOrEmpty(frame.ErrorCode) ? ProxyFetchErrorCodes.Error : frame.ErrorCode,
ErrorMessage = frame.ErrorMessage,
});
- return;
+ return true;
}
if (frame.Kind == ProxyFetchFrameKinds.Response)
@@ -106,15 +108,16 @@ internal void Feed(ProxyFetchResponsePayload frame)
}
catch (FormatException)
{
- return;
+ return true;
}
}
else
{
- return;
+ return true;
}
TryFinishByCount(pending);
+ return true;
}
}
diff --git a/src/WebContent/ProxyFetchWireCodec.cs b/src/WebContent/ProxyFetchWireCodec.cs
new file mode 100644
index 0000000..1145197
--- /dev/null
+++ b/src/WebContent/ProxyFetchWireCodec.cs
@@ -0,0 +1,92 @@
+using System;
+using System.IO;
+using System.Runtime.Serialization.Json;
+using System.Text;
+
+namespace WebContent
+{
+ ///
+ /// Wire codec for proxy-fetch payloads (#54 live-host fix). The platform's
+ /// ExternalMessage.Data rehydrates the payload via
+ /// "new DataContractSerializer(Type.GetType(DataClass))"; when the plugin assembly is not
+ /// visible to the HOST process's type resolution (Smart Client loads plugins differently
+ /// from Management Client), Type.GetType returns null, the platform swallows the resulting
+ /// ArgumentNullException ("Value cannot be null"), and hands the receiver Data == null - a
+ /// silent black hole that looks exactly like a lost response. Putting a plain string on the
+ /// wire removes the type-binding requirement from every host: payloads travel as JSON
+ /// strings, encoded/decoded only inside this plugin.
+ ///
+ internal static class ProxyFetchWireCodec
+ {
+ private static readonly DataContractJsonSerializer RequestSerializer =
+ new DataContractJsonSerializer(typeof(ProxyFetchRequestPayload));
+
+ private static readonly DataContractJsonSerializer FrameSerializer =
+ new DataContractJsonSerializer(typeof(ProxyFetchResponsePayload));
+
+ /// Encode a request payload for Message.Data.
+ internal static string ToWire(ProxyFetchRequestPayload payload)
+ {
+ return Serialize(payload, RequestSerializer);
+ }
+
+ /// Encode one response frame for Message.Data.
+ internal static string ToWire(ProxyFetchResponsePayload frame)
+ {
+ return Serialize(frame, FrameSerializer);
+ }
+
+ ///
+ /// Decode a request payload from a received Message.Data. Null for anything that is not
+ /// a decodable request JSON string (foreign/host values, garbage, version skew).
+ ///
+ internal static ProxyFetchRequestPayload RequestFromWire(object data)
+ {
+ var json = data as string;
+ if (json == null)
+ {
+ return null;
+ }
+
+ return Deserialize(json, RequestSerializer);
+ }
+
+ /// Decode one response frame from a received Message.Data; null when unusable.
+ internal static ProxyFetchResponsePayload FrameFromWire(object data)
+ {
+ var json = data as string;
+ if (json == null)
+ {
+ return null;
+ }
+
+ return Deserialize(json, FrameSerializer);
+ }
+
+ private static string Serialize(object payload, DataContractJsonSerializer serializer)
+ {
+ using (var stream = new MemoryStream())
+ {
+ serializer.WriteObject(stream, payload);
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+ }
+
+ private static T Deserialize(string json, DataContractJsonSerializer serializer) where T : class
+ {
+ try
+ {
+ using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)))
+ {
+ return serializer.ReadObject(stream) as T;
+ }
+ }
+ catch (Exception)
+ {
+ // Malformed or foreign content: an unusable frame, not an exception on the
+ // message-receiver thread.
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index f9e1c0b..5ae7cd0 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -107,6 +107,7 @@
+
diff --git a/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
index 4120c11..b8060b1 100644
--- a/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
@@ -56,11 +56,9 @@ public void ProbeProxyTestFetchOutcomes()
// NotAllowed failure frame, not a client-side timeout).
log.AppendLine($"non-allowlisted http://10.255.255.1/ -> {RunFetch(mcMain, urlBox, "http://10.255.255.1/")}");
- // 2) The workspace's allowlisted proxy target (the stored txtProxyTargets entry): proves
- // the ES-side workspace config resolve succeeded and the chunked success path
- // round-trips. The ES fetcher reaches https fine; only the SC browser's loopback leg
- // is http-only.
- log.AppendLine($"allowlisted https://joshooaj.com/ -> {RunFetch(mcMain, urlBox, "https://joshooaj.com/")}");
+ // 2) Same url the Smart Client browser just tried: discriminates an Event Server-side
+ // problem from a Smart Client-side one when SC's fetch times out but this succeeds.
+ log.AppendLine($"allowlisted http://httpforever.com/ -> {RunFetch(mcMain, urlBox, "http://httpforever.com/")}");
Artifacts.WriteText("proxy-probe-results.txt", log.ToString());
_output.WriteLine(log.ToString());
diff --git a/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
new file mode 100644
index 0000000..a462fb9
--- /dev/null
+++ b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
@@ -0,0 +1,126 @@
+using FlaUI.Core;
+using FlaUI.Core.AutomationElements;
+using WebContent.UxTests.Harness;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace WebContent.UxTests;
+
+///
+/// Diagnostic probe (#54): launches and logs in a Smart Client, opens the proxied
+/// "Demo Workspace", and navigates its browser view to the workspace's allowlisted
+/// http target. The view's WebView2 is pinned to the plugin's loopback forwarding
+/// proxy, so a rendered page proves the full browser -> loopback listener -> MIP
+/// messaging -> Event Server fetcher round-trip. Artifacts land in
+/// webcontent-sc-proxy-probe\<run> (sc-proxy-probe-*.png / .txt).
+///
+public class ScProxyFetchProbeTests
+{
+ private const string FetchUrl = "http://httpforever.com/";
+
+ private readonly ITestOutputHelper _output;
+
+ public ScProxyFetchProbeTests(ITestOutputHelper output) => _output = output;
+
+ [Fact]
+ [Trait("Category", "probe")]
+ public async Task ProbeProxiedWorkspaceBrowserFetch()
+ {
+ var config = UxConfig.Load();
+ Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe"));
+ _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}");
+
+ CdpConnector.CleanStalePortFiles(config.WebView2DataRoot);
+ ProcessKiller.KillClients("Client");
+ ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
+
+ using var sc = ClientSession.Launch(config.SmartClientExe);
+ var login = LoginFlow.WaitForLoginWindow(sc);
+ LoginFlow.Login(sc, login, config);
+ LoginFlow.AcceptCertificateTrust(sc);
+ var main = LoginFlow.WaitForSmartClientMain(sc);
+ SmartClientUi.Maximize(main);
+ Thread.Sleep(1500);
+ main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow");
+
+ var log = new System.Text.StringBuilder();
+ try
+ {
+ // SC-1 negative control (docs/verification-matrix.md): when the client machine's own
+ // route to the target is deliberately broken (hosts entry pointing it at 127.0.0.1),
+ // a direct hit must fail while the proxied page still renders - proving the traffic
+ // originates on the Event Server machine.
+ var blockedControl = Environment.GetEnvironmentVariable("WEBCONTENT_SC1_BLOCKED") == "1";
+
+ main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName);
+ var item = SmartClientUi.WaitForMipViewItem(main);
+ Assert.NotNull(item);
+
+ var addressBar = item.FindFirstDescendant(cf => cf.ByAutomationId("addressBar"));
+ if (addressBar == null)
+ {
+ throw new InvalidOperationException("No addressBar in the proxied workspace view; cannot drive the fetch probe.");
+ }
+
+ // Give the plugin's ProxyFetchClient filter registration its documented 3-4 s
+ // head start before the first fetch is attempted through the loopback listener.
+ Thread.Sleep(6000);
+
+ SmartClientUi.NavigateAddress(addressBar, FetchUrl);
+
+ await using var cdp = new CdpConnector();
+ var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot);
+ var page = await CdpConnector.WaitForPageAsync(browser, "httpforever.com");
+
+ // The loopback fetch has the MIP round-trip plus the Event Server's own GET to
+ // satisfy; give it well past the client-side 30 s deadline to be conclusive.
+ var body = await page.WaitForSelectorAsync("body", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 });
+ var text = body == null ? null : await body.TextContentAsync();
+ var title = await page.TitleAsync();
+
+ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
+ {
+ Path = Artifacts.PathOf("sc-proxy-probe-page.png"),
+ });
+
+ log.AppendLine($"navigated {FetchUrl} -> title='{title}' bodyChars={(text?.Length ?? -1)}");
+ if (!string.IsNullOrEmpty(text))
+ {
+ var snippet = text.Replace('\r', ' ').Replace('\n', ' ');
+ log.AppendLine($"body snippet: {snippet.Substring(0, Math.Min(200, snippet.Length))}");
+ }
+
+ if (blockedControl)
+ {
+ // The control is only meaningful if this very process cannot reach the target
+ // directly from the client machine while the browser view above did.
+ using var direct = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ try
+ {
+ var response = await direct.GetAsync(FetchUrl);
+ log.AppendLine($"direct client-side GET -> HTTP {(int)response.StatusCode} (control INVALID: direct path not broken)");
+ }
+ catch (Exception directFailure)
+ {
+ log.AppendLine($"direct client-side GET -> FAILED as required: {directFailure.GetType().Name}: {directFailure.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ log.AppendLine($"probe failed: {ex.GetType().Name}: {ex.Message}");
+ var fresh = sc.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow");
+ if (fresh != null)
+ {
+ Artifacts.Window(fresh, "sc-proxy-probe-failure");
+ }
+ }
+ finally
+ {
+ Artifacts.WriteText("sc-proxy-probe-results.txt", log.ToString());
+ _output.WriteLine(log.ToString());
+ ProcessKiller.KillClients("Client");
+ ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
+ }
+ }
+}
From 9cff27c84fdbbe19e0af6fc5a629ac325244b4ef Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 09:18:54 -0700
Subject: [PATCH 007/187] docs,test: code-review fixes for #54 - contract prose
matches JSON-string wire, SC-1 probe asserts (route-block pre-check,
error-page/body-size red), codec thread-safety note, matrix row drift
---
docs/verification-matrix.md | 21 ++---
src/WebContent/ProxyFetchContract.cs | 10 ++-
src/WebContent/ProxyFetchWireCodec.cs | 4 +
.../McProxyFetchProbeTests.cs | 3 +
.../ScProxyFetchProbeTests.cs | 78 ++++++++++++++-----
5 files changed, 82 insertions(+), 34 deletions(-)
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 577d405..2f76c1f 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -33,7 +33,7 @@ is not evidence the plugin never ran.
| Configuration error visibility | #9 | `WorkspaceDefinitionTests` (`ConfigurationError` classifies malformed JSON, blob-without-items, no-home-address item, nothing configured; malformed JSON reported even when legacy properties would rescue it; `SelectVisible` keeps named-but-broken tabs and still skips unnamed/unpermitted) | See MC-8 below: broken tab appears with a WorkSpaceNotification warning |
| SBOM integrity | #25 | `SbomGenerationGuardTests` (PostBuildEvent keeps a quoted absolute `-bc "$(MSBuildProjectDirectory)"` component path and `-D true`; the original bug let the CWD-relative `-bc .\` point at the build drop, so zero packages were detected) | `.\build.ps1` throws unless `output\WebContent\_manifest\spdx_2.2\manifest.spdx.json` lists referenced packages; CI Build job shows `SBOM OK` and no "no packages detected" annotation |
| Proxy configuration roundtrip | #52 | `ProxyConfigurationTests` (definitions saved before the proxy existed mean proxy-off; toggle+targets survive the serialized roundtrip), `ProxyPolicyTests` (admin entries canonicalize to scheme/host/port origins; garbage rejected; exact-origin matching; duplicates/overlaps collapse), `WorkspacePropertiesTests` (JSON-blob and legacy paths emit canonical `ProxyEnabled`/`ProxyTargets`, hand-crafted target gibberish skipped) | See MC-9 below: admin toggle round-trips to the Smart Client properties page; invalid target save refused with log marker |
-| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types are plugin-namespace `[Serializable]` with public fields — the whitelist case that keeps `MessageCommunicationSettings.config` unnecessary; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; timeout), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged |
+| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types stay plugin-namespace `[Serializable]` with public fields — wire shape itself is pinned by `ProxyFetchWireCodecTests` since #54; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; timeout), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged |
| Loopback forwarding proxy (Smart Client view consumes the fetch seam) | #54 | `ProxyForwardCodecTests` (absolute-form proxied request parsing; origin-form/non-http refused; CONNECT recognized by authority target; forwarded-response rendering: status line, content headers relayed, hop-by-hop dropped, Content-Length restated from the reassembled body; refusal pages), `ProxyForwardPolicyTests` (decision table via `ProxyPolicy`: allowlisted GET forwards, other origins 403, non-GET 405, CONNECT 502, malformed 400 — address-bar navigation follows the same rule), `ProxyForwardConnectionTests` (full stream flow: forward+relay, 403 without touching the fetcher, Unreachable→502, Timeout→504), `ProxyBrowserArgumentsTests` (every http/https/ws scheme pinned to the listener, `<-loopback` bypass off — never direct), `ProxyWorkspaceProfileTests` (enabled+targets → listener allowlist; duplicates collapse; enabled-with-no-targets still proxies and allows nothing; disabled → no profile), `ProxyServerAllowlistTests` (ES refuses origins no proxied workspace allowlists, proxy-off workspaces don't count, closed before first resolve, stale-on-error keeps serving the last snapshot) | See SC-1 below: with the drop deployed on both the Event Server machine and the Smart Client machine, and the client's route to the target killed, the proxied page still loads — proof the traffic source is the server, not the client |
## Manual host checks
@@ -178,14 +178,17 @@ device or any http page — https is refused by design, see below).
Automatable harness: `tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs` (xunit
`Category=probe`) launches Smart Client, logs in, selects the proxied workspace, navigates the
allowlisted target, and writes `sc-proxy-probe-results.txt` (+ page screenshot) under
- `%TEMP%\webcontent-sc-proxy-probe\`. For row 3's negative control, add the client-machine
- hosts entry `127.0.0.1 # webcontent-sc-1-block` and run with
- `WEBCONTENT_SC1_BLOCKED=1`: the probe then logs a direct client-side GET that MUST fail while
- the rendered page still comes through the proxy.
+ `%TEMP%\webcontent-sc-proxy-probe\`. The probe goes red — not just records — when the
+ render is a proxy error page or the body is smaller than the real target page. For row 3's
+ negative control, add the client-machine hosts entry `127.0.0.1 #
+ webcontent-sc-1-block` and run with `WEBCONTENT_SC1_BLOCKED=1`: the probe first pre-checks
+ that a direct client-side GET already fails (control established BEFORE the render), then
+ requires the proxied page to render while the direct route stays broken.
Record (2026-10-31, drop `AF65D193…7FF9` on both machines, target `http://httpforever.com/`):
- page rendered (`title='HTTP Forever - A reliably insecure connection'`, 2103 body chars);
- blocked control rendered identically while the same process's direct GET failed with
- `actively refused it` — server-origin proof satisfied.
+ rows 1–3 pass — page rendered (`title='HTTP Forever - A reliably insecure connection'`, 2103
+ body chars); the blocked control rendered identically while the same process's direct GET
+ failed with `actively refused it` — server-origin proof satisfied. Rows 4–6 are pinned by the
+ unit table (403/405/502 policy table, listener teardown) rather than a live probe run.
## Known high-risk regressions → pinned by
@@ -201,7 +204,7 @@ device or any http page — https is refused by design, see below).
| Broken workspace config silently hiding its tab again (#9) | `WorkspaceDefinitionTests` (`ConfigurationError` + `SelectVisible` visibility pins), MC-8 |
| Proxy allowlist lost on toggle-off, or proxy silently defaulting on for old definitions (#52) | `ProxyConfigurationTests` (legacy blob = proxy off; roundtrip), MC-9 |
| Inconsistent proxy-target canonicalization between admin and runtime flows (#52) | `ProxyPolicyTests` + `WorkspacePropertiesTests` (shared `ProxyPolicy` canonical form in both save and Build paths) |
-| Fetch message ids renamed to something ending in "Command", or payloads moved to a non-whitelisted type, silently breaking SecurityLevel.Normal clients or deserialization (#53) | `ProxyFetchContractGuardTests` (id-suffix + serializability/whitelist pins), ES-1 |
+| Fetch message ids renamed to something ending in "Command", silently breaking SecurityLevel.Normal clients (#53) | `ProxyFetchContractGuardTests` (id-suffix pins), ES-1 |
| Chunk framer or correlation reassembler losing frames/order so the client sees truncated bodies (#53) | `ProxyFetchFramerTests`, `ProxyFetchCorrelationTests`, `ProxyFetchTransportTests` |
| Proxied workspace silently reaching targets direct, or driving the ES fetcher at arbitrary origins (#54) | `ProxyForwardPolicyTests` (403/405/502 table), `ProxyBrowserArgumentsTests` (no scheme falls back to direct), `ProxyWorkspaceProfileTests` (enabled always proxies), `ProxyServerAllowlistTests` (ES-side union gate, closed before first resolve), SC-1 |
| Reassembled body framed with the target's stale Content-Length or hop-by-hop headers, corrupting the rendered page (#54) | `ProxyForwardCodecTests` (Content-Length restated, hop-by-hop dropped) |
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index f4fe9bb..dedbb75 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -66,8 +66,10 @@ internal static class ProxyFetchErrorCodes
///
/// One response header from the fetched target, as the fetcher's HTTP layer captured it.
- /// Plugin-namespace payload type, so the Event Server whitelist lets it through. Mirrors the
- /// Chat sample's ChatData wire shape: public [Serializable] class with public fields.
+ /// This type NEVER travels on the wire as an object: it is a member of
+ /// , and the whole payload crosses messaging only as a
+ /// JSON string via (#54 - hosts like Smart Client cannot
+ /// bind plugin-namespace types through the platform's Type.GetType rehydration).
///
[Serializable]
public class ProxyFetchHeader
@@ -89,8 +91,8 @@ public class ProxyFetchRequestPayload
///
/// Response payload for every frame kind (Response / Chunk / Failure). Fields irrelevant to a
- /// kind stay null/default. Plugin-namespace type built only from whitelist-safe primitives and
- /// List<ProxyFetchHeader>.
+ /// kind stay null/default. Serialized to a JSON string by
+ /// before transmit; the platform must never be asked to bind this type itself.
///
[Serializable]
public class ProxyFetchResponsePayload
diff --git a/src/WebContent/ProxyFetchWireCodec.cs b/src/WebContent/ProxyFetchWireCodec.cs
index 1145197..b400987 100644
--- a/src/WebContent/ProxyFetchWireCodec.cs
+++ b/src/WebContent/ProxyFetchWireCodec.cs
@@ -18,6 +18,10 @@ namespace WebContent
///
internal static class ProxyFetchWireCodec
{
+ // DataContractJsonSerializer instances are documented as thread-safe for concurrent
+ // ReadObject/WriteObject when the type is fixed at construction (as here): the fetcher
+ // runs up to its concurrency cap on the thread pool and the client receives on the
+ // messaging thread, all against these same instances.
private static readonly DataContractJsonSerializer RequestSerializer =
new DataContractJsonSerializer(typeof(ProxyFetchRequestPayload));
diff --git a/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
index b8060b1..fa5331a 100644
--- a/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs
@@ -58,6 +58,9 @@ public void ProbeProxyTestFetchOutcomes()
// 2) Same url the Smart Client browser just tried: discriminates an Event Server-side
// problem from a Smart Client-side one when SC's fetch times out but this succeeds.
+ // (http, not https: the workspace allowlist is scheme-exact and proxied browsers
+ // refuse https targets by design, so this mirrors the live SC-1 traffic. ES-side
+ // https fetching stays covered by MC-10 manual step 3's NotAllowed probe only.)
log.AppendLine($"allowlisted http://httpforever.com/ -> {RunFetch(mcMain, urlBox, "http://httpforever.com/")}");
Artifacts.WriteText("proxy-probe-results.txt", log.ToString());
diff --git a/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
index a462fb9..36b80b3 100644
--- a/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
@@ -44,14 +44,13 @@ public async Task ProbeProxiedWorkspaceBrowserFetch()
main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow");
var log = new System.Text.StringBuilder();
+ string failure = null;
+ string title = null;
+ var bodyChars = -1;
+ var directReachedTarget = false;
+ var blockedControl = Environment.GetEnvironmentVariable("WEBCONTENT_SC1_BLOCKED") == "1";
try
{
- // SC-1 negative control (docs/verification-matrix.md): when the client machine's own
- // route to the target is deliberately broken (hosts entry pointing it at 127.0.0.1),
- // a direct hit must fail while the proxied page still renders - proving the traffic
- // originates on the Event Server machine.
- var blockedControl = Environment.GetEnvironmentVariable("WEBCONTENT_SC1_BLOCKED") == "1";
-
main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName);
var item = SmartClientUi.WaitForMipViewItem(main);
Assert.NotNull(item);
@@ -66,6 +65,22 @@ public async Task ProbeProxiedWorkspaceBrowserFetch()
// head start before the first fetch is attempted through the loopback listener.
Thread.Sleep(6000);
+ // SC-1 negative control (docs/verification-matrix.md): when the client machine's own
+ // route to the target is deliberately broken (hosts entry pointing it at 127.0.0.1),
+ // a direct hit must fail while the proxied page still renders - proving the traffic
+ // originates on the Event Server machine. Establish the block BEFORE rendering, so a
+ // run whose block is missing goes red instead of reporting the gap post hoc.
+ if (blockedControl)
+ {
+ directReachedTarget = await DirectGetReachesTargetAsync();
+ if (directReachedTarget)
+ {
+ throw new InvalidOperationException(
+ "control INVALID: the client machine still reaches the target directly; add the hosts entry (127.0.0.1 httpforever.com # webcontent-sc-1-block) before this run");
+ }
+ log.AppendLine("control pre-check: direct client-side GET already fails (route is blocked)");
+ }
+
SmartClientUi.NavigateAddress(addressBar, FetchUrl);
await using var cdp = new CdpConnector();
@@ -76,14 +91,15 @@ public async Task ProbeProxiedWorkspaceBrowserFetch()
// satisfy; give it well past the client-side 30 s deadline to be conclusive.
var body = await page.WaitForSelectorAsync("body", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 });
var text = body == null ? null : await body.TextContentAsync();
- var title = await page.TitleAsync();
+ title = await page.TitleAsync();
+ bodyChars = text?.Length ?? -1;
await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
{
Path = Artifacts.PathOf("sc-proxy-probe-page.png"),
});
- log.AppendLine($"navigated {FetchUrl} -> title='{title}' bodyChars={(text?.Length ?? -1)}");
+ log.AppendLine($"navigated {FetchUrl} -> title='{title}' bodyChars={bodyChars}");
if (!string.IsNullOrEmpty(text))
{
var snippet = text.Replace('\r', ' ').Replace('\n', ' ');
@@ -92,23 +108,17 @@ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
if (blockedControl)
{
- // The control is only meaningful if this very process cannot reach the target
- // directly from the client machine while the browser view above did.
- using var direct = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(10) };
- try
- {
- var response = await direct.GetAsync(FetchUrl);
- log.AppendLine($"direct client-side GET -> HTTP {(int)response.StatusCode} (control INVALID: direct path not broken)");
- }
- catch (Exception directFailure)
- {
- log.AppendLine($"direct client-side GET -> FAILED as required: {directFailure.GetType().Name}: {directFailure.Message}");
- }
+ // Re-check after the render: the control must still hold at verdict time.
+ directReachedTarget = await DirectGetReachesTargetAsync();
+ log.AppendLine(directReachedTarget
+ ? "direct client-side GET -> reached the target (control INVALID: direct path not broken)"
+ : "direct client-side GET -> FAILED as required while the proxied page rendered");
}
}
catch (Exception ex)
{
- log.AppendLine($"probe failed: {ex.GetType().Name}: {ex.Message}");
+ failure = $"{ex.GetType().Name}: {ex.Message}";
+ log.AppendLine($"probe failed: {failure}");
var fresh = sc.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow");
if (fresh != null)
{
@@ -122,5 +132,31 @@ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
ProcessKiller.KillClients("Client");
ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
}
+
+ // The probe goes RED: evidence in the artifacts file is for humans, but a proxy that
+ // never completes, an error page that renders instead of the target, or a control run
+ // whose direct route was never broken must fail the run, not merely record it.
+ Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}");
+ Assert.NotNull(title);
+ Assert.False(title.StartsWith("Web Content proxy:"), $"browser showed the proxy error page instead of the target: '{title}'");
+ Assert.True(bodyChars >= 500, $"target body too small to be the real page ({bodyChars} chars)");
+ if (blockedControl)
+ {
+ Assert.False(directReachedTarget, "SC-1 control invalid: the client reached the target directly while the proxied page rendered");
+ }
+ }
+
+ private static async Task DirectGetReachesTargetAsync()
+ {
+ using var direct = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ try
+ {
+ await direct.GetAsync(FetchUrl);
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
}
}
From 8c7d70940278eee1d53ad1e21447d4945ddc8828 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 09:53:43 -0700
Subject: [PATCH 008/187] feat(proxy): forward POST/PUT/DELETE/OPTIONS with
method, headers, and body; cookie-pure passthrough (#55)
The loopback forwarder stops being GET-only: browser login POSTs and
background XHR cross the MIP wire via ProxyFetchRequestPayload
{Method, Headers, BodyBase64} - still ONE JSON string per #54, never
plugin types. New ProxyFetchQuery seam carries uri/method/headers/body
through listener -> BrowserManager -> ProxyFetchClient and into
ProxyHttpFetcher.
- decision table: GET/HEAD/POST/PUT/DELETE/OPTIONS/PATCH forward, TRACE
and junk verbs 405, CONNECT stays 502; ES re-checks the same shared
IsForwardedMethod table against decoded payloads
- request bodies read per Content-Length (1 MiB cap -> 413 before any
bytes are taken; chunked -> 411; short body -> 400)
- outbound headers: Cookie/Authorization/Content-Type/custom pass
verbatim; hop-by-hop + Host + Content-Length dropped (client AND
transport both filter, so a crafted payload can't restate them)
- session purity: AllowAutoRedirect off (3xx+Location relayed), no
cookie store on the VMS server (UseCookies off via guarded set - the
property is absent on compat shims); repeated Set-Cookie captured per
raw value so the comma-join can't corrupt the session
---
docs/verification-matrix.md | 29 ++-
.../ProxyFetchReplyGuardTests.cs | 2 +-
.../ProxyFetchTransportTests.cs | 123 ++++++++++-
.../ProxyFetchWireCodecTests.cs | 50 +++++
.../ProxyForwardCodecTests.cs | 36 ++++
.../ProxyForwardConnectionTests.cs | 200 +++++++++++++++---
.../ProxyForwardPolicyTests.cs | 43 +++-
src/WebContent/Client/BrowserManager.cs | 7 +-
.../Client/LoopbackProxyListener.cs | 4 +-
src/WebContent/ProxyFetchBackgroundPlugin.cs | 41 +++-
src/WebContent/ProxyFetchClient.cs | 27 ++-
src/WebContent/ProxyFetchContract.cs | 37 +++-
src/WebContent/ProxyFetchTransport.cs | 187 +++++++++++++++-
src/WebContent/ProxyForwardCodec.cs | 48 ++++-
src/WebContent/ProxyForwardConnection.cs | 195 +++++++++++++++--
src/WebContent/ProxyForwardPolicy.cs | 33 ++-
16 files changed, 965 insertions(+), 97 deletions(-)
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 2f76c1f..bff2709 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -33,8 +33,8 @@ is not evidence the plugin never ran.
| Configuration error visibility | #9 | `WorkspaceDefinitionTests` (`ConfigurationError` classifies malformed JSON, blob-without-items, no-home-address item, nothing configured; malformed JSON reported even when legacy properties would rescue it; `SelectVisible` keeps named-but-broken tabs and still skips unnamed/unpermitted) | See MC-8 below: broken tab appears with a WorkSpaceNotification warning |
| SBOM integrity | #25 | `SbomGenerationGuardTests` (PostBuildEvent keeps a quoted absolute `-bc "$(MSBuildProjectDirectory)"` component path and `-D true`; the original bug let the CWD-relative `-bc .\` point at the build drop, so zero packages were detected) | `.\build.ps1` throws unless `output\WebContent\_manifest\spdx_2.2\manifest.spdx.json` lists referenced packages; CI Build job shows `SBOM OK` and no "no packages detected" annotation |
| Proxy configuration roundtrip | #52 | `ProxyConfigurationTests` (definitions saved before the proxy existed mean proxy-off; toggle+targets survive the serialized roundtrip), `ProxyPolicyTests` (admin entries canonicalize to scheme/host/port origins; garbage rejected; exact-origin matching; duplicates/overlaps collapse), `WorkspacePropertiesTests` (JSON-blob and legacy paths emit canonical `ProxyEnabled`/`ProxyTargets`, hand-crafted target gibberish skipped) | See MC-9 below: admin toggle round-trips to the Smart Client properties page; invalid target save refused with log marker |
-| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types stay plugin-namespace `[Serializable]` with public fields — wire shape itself is pinned by `ProxyFetchWireCodecTests` since #54; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; timeout), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged |
-| Loopback forwarding proxy (Smart Client view consumes the fetch seam) | #54 | `ProxyForwardCodecTests` (absolute-form proxied request parsing; origin-form/non-http refused; CONNECT recognized by authority target; forwarded-response rendering: status line, content headers relayed, hop-by-hop dropped, Content-Length restated from the reassembled body; refusal pages), `ProxyForwardPolicyTests` (decision table via `ProxyPolicy`: allowlisted GET forwards, other origins 403, non-GET 405, CONNECT 502, malformed 400 — address-bar navigation follows the same rule), `ProxyForwardConnectionTests` (full stream flow: forward+relay, 403 without touching the fetcher, Unreachable→502, Timeout→504), `ProxyBrowserArgumentsTests` (every http/https/ws scheme pinned to the listener, `<-loopback` bypass off — never direct), `ProxyWorkspaceProfileTests` (enabled+targets → listener allowlist; duplicates collapse; enabled-with-no-targets still proxies and allows nothing; disabled → no profile), `ProxyServerAllowlistTests` (ES refuses origins no proxied workspace allowlists, proxy-off workspaces don't count, closed before first resolve, stale-on-error keeps serving the last snapshot) | See SC-1 below: with the drop deployed on both the Event Server machine and the Smart Client machine, and the client's route to the target killed, the proxied page still loads — proof the traffic source is the server, not the client |
+| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types stay plugin-namespace `[Serializable]` with public fields — wire shape itself is pinned by `ProxyFetchWireCodecTests` since #54; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; timeout), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable; #55: method/form body/Cookie+custom headers replayed at the target, 302+Set-Cookie relayed instead of followed, repeated Set-Cookie values captured separately), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged |
+| Loopback forwarding proxy (Smart Client view consumes the fetch seam) | #54, write verbs #55 | `ProxyForwardCodecTests` (absolute-form proxied request parsing; origin-form/non-http refused; CONNECT recognized by authority target; outbound request-header filter keeps Cookie/Authorization/custom headers and drops hop-by-hop+Host+Content-Length; forwarded-response rendering: status line, content headers relayed, hop-by-hop dropped, Content-Length restated from the reassembled body; refusal pages), `ProxyForwardPolicyTests` (decision table via `ProxyPolicy`: allowlisted GET/HEAD/POST/PUT/DELETE/OPTIONS/PATCH forward, other origins 403, TRACE/junk verbs 405, CONNECT 502, malformed 400 — address-bar navigation follows the same rule), `ProxyForwardConnectionTests` (full stream flow: forward+relay, POST method/form body/Cookie to the fetch seam, 302+Set-Cookie relayed back, repeated Set-Cookie rendered as separate lines, 403 without touching the fetcher, oversized declared body 413, chunked body 411, Unreachable→502, Timeout→504), `ProxyBrowserArgumentsTests` (every http/https/ws scheme pinned to the listener, `<-loopback` bypass off — never direct), `ProxyWorkspaceProfileTests` (enabled+targets → listener allowlist; duplicates collapse; enabled-with-no-targets still proxies and allows nothing; disabled → no profile), `ProxyServerAllowlistTests` (ES refuses origins no proxied workspace allowlists, proxy-off workspaces don't count, closed before first resolve, stale-on-error keeps serving the last snapshot) | See SC-1 below: with the drop deployed on both the Event Server machine and the Smart Client machine, and the client's route to the target killed, the proxied page still loads — proof the traffic source is the server, not the client. See SC-2: a login POST round-trips with its session cookie in a live Smart Client session |
## Manual host checks
@@ -167,7 +167,8 @@ device or any http page — https is refused by design, see below).
4. Address-bar navigation to a NON-allowlisted origin in the proxied workspace shows the proxy's
403 page ("not in the workspace proxy allowlist"), and no request leaves for it (the fetcher
is never asked). Navigation to an https:// origin shows the 502 CONNECT-refusal page — https
- is never taken direct. A POST/HTML form submit shows the 405 page.
+ is never taken direct. Since #55 a POST/HTML form submit is NOT a 405: it forwards with its
+ body (see SC-2); only verbs outside the relay set (TRACE and friends) show the 405 page.
5. Event Server `Logs\MIP.log` during 3: successes are silent; failure cases (4) log the
refusal/`Proxy fetch failed` markers from ES-1/MC-10.
6. Remove the view item from the layout, or exit the Smart Client: the listener stops with the
@@ -190,6 +191,25 @@ device or any http page — https is refused by design, see below).
failed with `actively refused it` — server-origin proof satisfied. Rows 4–6 are pinned by the
unit table (403/405/502 policy table, listener teardown) rather than a live probe run.
+### SC-2 Vendor login flow through the proxy (#55)
+
+Requires the same both-machine drop as SC-1 (the #55 wire contract carries method/headers/body,
+so client and Event Server MUST run the same build — deploys are simultaneous or every payload
+decodes as an unusable frame).
+
+1. Allowlist an http target that serves a login form: a real camera page, or the probe login
+ server (`tests/ux/WebContent.UxTests`, `Category=probe` serves `/login` → POST → 302 +
+ `Set-Cookie` → `/dashboard` gated on the cookie) on a machine the Event Server can reach.
+2. In the proxied Smart Client workspace, submit the login form. Expected: the POST is relayed
+ (no 405), the 302 + `Set-Cookie` come back untouched, and the follow-up GET of the landing
+ page carries the browser's `Cookie` header to the fetcher — the landing page shows the
+ signed-in state, not the form again.
+3. The session cookie lives ONLY in the WebView2 profile: no cookie store exists on the Event
+ Server side (`UseCookies`/`AllowAutoRedirect` off — pinned by `ProxyFetchTransportTests`).
+4. Automatable harness: extend `ScProxyFetchProbeTests` (probe category) — artifacts under
+ `%TEMP%\webcontent-sc-proxy-probe\`; the probe goes red when the dashboard is not
+ reached or the cookie round-trip fails.
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
@@ -209,6 +229,9 @@ device or any http page — https is refused by design, see below).
| Proxied workspace silently reaching targets direct, or driving the ES fetcher at arbitrary origins (#54) | `ProxyForwardPolicyTests` (403/405/502 table), `ProxyBrowserArgumentsTests` (no scheme falls back to direct), `ProxyWorkspaceProfileTests` (enabled always proxies), `ProxyServerAllowlistTests` (ES-side union gate, closed before first resolve), SC-1 |
| Reassembled body framed with the target's stale Content-Length or hop-by-hop headers, corrupting the rendered page (#54) | `ProxyForwardCodecTests` (Content-Length restated, hop-by-hop dropped) |
| Proxy-fetch payloads moved back from JSON strings to plugin-namespace types, silently null-ing `Message.Data` on hosts whose type resolution cannot see WebContent.dll (the Smart Client black hole) (#54) | `ProxyFetchWireCodecTests` (round-trip + foreign-content pins), `ProxyFetchReplyGuardTests.BothSidesCarryPayloadsAsJsonStringsNeverAsPluginTypes` (both transmit sides), ES-1 bullet 3, SC-1 |
+| Repeated `Set-Cookie` values comma-joined or login 3xx followed server-side, so the session cookie never reaches the browser and vendor logins loop back to the form (#55) | `ProxyFetchTransportTests` (repeated Set-Cookie captured per value; 302+Set-Cookie relayed, never followed), `ProxyForwardConnectionTests` (separate Set-Cookie lines rendered; POST body+Cookie to the seam), SC-2 |
+| Request method tables drifting between the client forwarder and the Event Server, so a browser POST forwards locally then dies as a server-side refusal (#55) | One shared `ProxyForwardPolicy.IsForwardedMethod` table used on both sides; `ProxyForwardPolicyTests` (verb table), `ProxyFetchTransportTests` (verb replay) |
+| Cookie/Authorization headers silently stripped at the proxy, breaking exactly the login flows #55 exists for | `ProxyForwardCodecTests.ToOutboundRequestHeaders` pins (Cookie/Authorization/custom kept verbatim; only hop-by-hop+Host+Content-Length dropped), `ProxyFetchTransportTests` replay pin |
## Scope notes (deliberate)
diff --git a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
index ae67f4a..b7126d8 100644
--- a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
+++ b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
@@ -38,7 +38,7 @@ public void EveryReplyTargetsTheResolvedRequesterEndpoint()
// reply paths.
Assert.Contains("TransmitFailure(payload.CorrelationId, requester,", fetcher);
Assert.DoesNotContain("TransmitFailure(payload.CorrelationId, source,", fetcher);
- Assert.Contains("ServeFetchAsync(communication, payload, target, requester)", fetcher);
+ Assert.Contains("ServeFetchAsync(communication, payload, query, requester)", fetcher);
}
[Fact]
diff --git a/src/WebContent.Tests/ProxyFetchTransportTests.cs b/src/WebContent.Tests/ProxyFetchTransportTests.cs
index 9a21b3b..f225b49 100644
--- a/src/WebContent.Tests/ProxyFetchTransportTests.cs
+++ b/src/WebContent.Tests/ProxyFetchTransportTests.cs
@@ -33,7 +33,7 @@ public async Task FetchingTheTestPageCapturesStatusHeadersAndBody()
};
var fetcher = new ProxyHttpFetcher();
- var result = await fetcher.FetchAsync(new Uri(_server.UrlFor("/index.html")), ProxyFetchLimits.Default());
+ var result = await fetcher.FetchAsync(Get(_server.UrlFor("/index.html")), ProxyFetchLimits.Default());
Assert.True(result.Success);
Assert.Equal(200, result.StatusCode);
@@ -58,7 +58,7 @@ public async Task BodyOverTheMaximumResponseSizeIsRefusedWithTooLarge()
var limits = ProxyFetchLimits.Default();
limits.MaxResponseBytes = 1000;
- var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/large")), limits);
+ var result = await new ProxyHttpFetcher().FetchAsync(Get(_server.UrlFor("/large")), limits);
Assert.False(result.Success);
Assert.Equal(ProxyFetchErrorCodes.TooLarge, result.ErrorCode);
@@ -83,7 +83,7 @@ public async Task BodyStallingPastTheReadTimeoutIsRefusedWithTimeout()
var limits = ProxyFetchLimits.Default();
limits.ReadTimeoutMs = 100;
- var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/stalling")), limits);
+ var result = await new ProxyHttpFetcher().FetchAsync(Get(_server.UrlFor("/stalling")), limits);
Assert.False(result.Success);
Assert.Equal(ProxyFetchErrorCodes.Timeout, result.ErrorCode);
@@ -102,7 +102,7 @@ public async Task HeadersStallingPastTheConnectBudgetIsRefusedWithTimeout()
var limits = ProxyFetchLimits.Default();
limits.ConnectTimeoutMs = 200;
- var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/silent")), limits);
+ var result = await new ProxyHttpFetcher().FetchAsync(Get(_server.UrlFor("/silent")), limits);
Assert.False(result.Success);
Assert.Equal(ProxyFetchErrorCodes.Timeout, result.ErrorCode);
@@ -112,7 +112,7 @@ public async Task HeadersStallingPastTheConnectBudgetIsRefusedWithTimeout()
public async Task AnUnreachableTargetReportsUnreachableNotSuccess()
{
var unusedPort = FreePort();
- var result = await new ProxyHttpFetcher().FetchAsync(new Uri($"http://127.0.0.1:{unusedPort}/nope"), ProxyFetchLimits.Default());
+ var result = await new ProxyHttpFetcher().FetchAsync(Get($"http://127.0.0.1:{unusedPort}/nope"), ProxyFetchLimits.Default());
Assert.False(result.Success);
Assert.Equal(ProxyFetchErrorCodes.Unreachable, result.ErrorCode);
@@ -129,7 +129,7 @@ public async Task AnHttpErrorStatusIsStillPassedThroughAsAStatusLine()
context.Response.Close();
};
- var result = await new ProxyHttpFetcher().FetchAsync(new Uri(_server.UrlFor("/missing")), ProxyFetchLimits.Default());
+ var result = await new ProxyHttpFetcher().FetchAsync(Get(_server.UrlFor("/missing")), ProxyFetchLimits.Default());
// The proxy relays whatever the target answered; a 404 page is still a page.
Assert.True(result.Success);
@@ -142,12 +142,121 @@ public async Task AnHttpErrorStatusIsStillPassedThroughAsAStatusLine()
[InlineData("file:///C:/windows/win.ini")]
public async Task NonHttpSchemeIsRefusedWithoutTouchingTheNetwork(string target)
{
- var result = await new ProxyHttpFetcher().FetchAsync(new Uri(target), ProxyFetchLimits.Default());
+ var result = await new ProxyHttpFetcher().FetchAsync(Get(target), ProxyFetchLimits.Default());
Assert.False(result.Success);
Assert.Equal(ProxyFetchErrorCodes.BadRequest, result.ErrorCode);
}
+ [Fact]
+ public async Task ReplaysMethodRequestBodyAndRequestHeadersAtTheTarget()
+ {
+ // #55 AC1/AC2: the vendor login POST arrives at the target with its verb, form body,
+ // Cookie header, content type, and custom app headers intact.
+ string observedMethod = null;
+ string observedBody = null;
+ string observedCookie = null;
+ string observedVendorHeader = null;
+ string observedContentType = null;
+ _server.Handler = async context =>
+ {
+ observedMethod = context.Request.HttpMethod;
+ using (var reader = new StreamReader(context.Request.InputStream))
+ {
+ observedBody = await reader.ReadToEndAsync();
+ }
+
+ observedCookie = context.Request.Headers["Cookie"];
+ observedVendorHeader = context.Request.Headers["X-Vendor-Auth"];
+ observedContentType = context.Request.ContentType;
+
+ var bytes = Encoding.UTF8.GetBytes("welcome");
+ await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length);
+ context.Response.Close();
+ };
+
+ var query = new ProxyFetchQuery
+ {
+ Uri = new Uri(_server.UrlFor("/login")),
+ Method = "POST",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Cookie", Value = "sid=abc" },
+ new ProxyFetchHeader { Name = "X-Vendor-Auth", Value = "t-7" },
+ new ProxyFetchHeader { Name = "Content-Type", Value = "application/x-www-form-urlencoded" },
+ },
+ Body = Encoding.UTF8.GetBytes("user=admin&pass=x"),
+ };
+
+ var result = await new ProxyHttpFetcher().FetchAsync(query, ProxyFetchLimits.Default());
+
+ Assert.True(result.Success);
+ Assert.Equal("POST", observedMethod);
+ Assert.Equal("user=admin&pass=x", observedBody);
+ Assert.Equal("sid=abc", observedCookie);
+ Assert.Equal("t-7", observedVendorHeader);
+ Assert.NotNull(observedContentType);
+ Assert.StartsWith("application/x-www-form-urlencoded", observedContentType);
+ Assert.Equal(Encoding.UTF8.GetBytes("welcome"), result.Body);
+ }
+
+ [Fact]
+ public async Task RelaysRedirectsAndSetCookiesInsteadOfFollowingOrStoringThem()
+ {
+ // #55 cookie round-trip: the fetcher answers the 3xx as a completed response with the
+ // Set-Cookie and Location headers intact; it never follows the redirect (that is the
+ // browser's move, back through this same proxy) and keeps no session state itself.
+ _server.Handler = context =>
+ {
+ context.Response.StatusCode = 302;
+ context.Response.RedirectLocation = "/dashboard";
+ context.Response.Headers.Add("Set-Cookie", "SID=xyz; Path=/; HttpOnly");
+ context.Response.Close();
+ return Task.CompletedTask;
+ };
+
+ var result = await new ProxyHttpFetcher().FetchAsync(Get(_server.UrlFor("/login")), ProxyFetchLimits.Default());
+
+ Assert.True(result.Success);
+ Assert.Equal(302, result.StatusCode);
+ Assert.Contains(result.Headers, h => h.Name == "Set-Cookie" && h.Value == "SID=xyz; Path=/; HttpOnly");
+ Assert.Contains(result.Headers, h => h.Name == "Location" && h.Value == "/dashboard");
+ }
+
+ [Fact]
+ public async Task CapturesRepeatedSetCookieValuesAsSeparateHeaders()
+ {
+ // The comma-join hazard: two Set-Cookie values must survive capture as two entries,
+ // not one joined string that the browser then rejects.
+ _server.Handler = context =>
+ {
+ context.Response.Headers.Add("Set-Cookie", "SID=a; Path=/");
+ context.Response.Headers.Add("Set-Cookie", "CSRF=b; Path=/; Secure");
+ context.Response.Close();
+ return Task.CompletedTask;
+ };
+
+ var result = await new ProxyHttpFetcher().FetchAsync(Get(_server.UrlFor("/two-cookies")), ProxyFetchLimits.Default());
+
+ Assert.True(result.Success);
+ var setCookies = new List();
+ foreach (var header in result.Headers)
+ {
+ if (header.Name == "Set-Cookie")
+ {
+ setCookies.Add(header.Value);
+ }
+ }
+
+ Assert.Contains("SID=a; Path=/", setCookies);
+ Assert.Contains("CSRF=b; Path=/; Secure", setCookies);
+ }
+
+ private static ProxyFetchQuery Get(string url)
+ {
+ return new ProxyFetchQuery { Uri = new Uri(url), Method = "GET" };
+ }
+
public void Dispose()
{
_server.Dispose();
diff --git a/src/WebContent.Tests/ProxyFetchWireCodecTests.cs b/src/WebContent.Tests/ProxyFetchWireCodecTests.cs
index 88d6e5b..51f1ab6 100644
--- a/src/WebContent.Tests/ProxyFetchWireCodecTests.cs
+++ b/src/WebContent.Tests/ProxyFetchWireCodecTests.cs
@@ -29,6 +29,56 @@ public void RequestRoundTripsThroughAString()
Assert.Equal("http://httpforever.com/", back.Url);
}
+ [Fact]
+ public void RequestCarriesMethodHeadersAndBase64BodyOnTheWire()
+ {
+ // #55: login POSTs cross the wire with method, browser request headers, and body —
+ // still as ONE JSON string, never as plugin types (the #54 Smart Client black hole).
+ var body = System.Text.Encoding.UTF8.GetBytes("user=admin&pass=s3cret&submit=Login");
+ var wire = ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload
+ {
+ CorrelationId = "corr-post",
+ Url = "http://10.0.0.5:8080/login",
+ Method = "POST",
+ Headers = new System.Collections.Generic.List
+ {
+ new ProxyFetchHeader { Name = "Content-Type", Value = "application/x-www-form-urlencoded" },
+ new ProxyFetchHeader { Name = "Cookie", Value = "sid=abc123" },
+ new ProxyFetchHeader { Name = "X-Vendor-Auth", Value = "token-9" },
+ },
+ BodyBase64 = Convert.ToBase64String(body),
+ });
+
+ Assert.IsType(wire);
+
+ var back = ProxyFetchWireCodec.RequestFromWire(wire);
+ Assert.NotNull(back);
+ Assert.Equal("corr-post", back.CorrelationId);
+ Assert.Equal("POST", back.Method);
+ Assert.Equal("Cookie", back.Headers[1].Name);
+ Assert.Equal("sid=abc123", back.Headers[1].Value);
+ Assert.Equal("X-Vendor-Auth", back.Headers[2].Name);
+ Assert.Equal(body, Convert.FromBase64String(back.BodyBase64));
+ }
+
+ [Fact]
+ public void PlainGetRequestsStillDecodeWithNullMethodHeadersAndBody()
+ {
+ // Field additions must not disturb the GET path: absent fields decode to null, and
+ // the fetcher treats a null method as GET.
+ var wire = ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload
+ {
+ CorrelationId = "corr-get",
+ Url = "http://httpforever.com/",
+ });
+
+ var back = ProxyFetchWireCodec.RequestFromWire(wire);
+ Assert.NotNull(back);
+ Assert.Null(back.Method);
+ Assert.Null(back.Headers);
+ Assert.Null(back.BodyBase64);
+ }
+
[Fact]
public void ResponseFrameRoundTripsEveryFieldTheProtocolUses()
{
diff --git a/src/WebContent.Tests/ProxyForwardCodecTests.cs b/src/WebContent.Tests/ProxyForwardCodecTests.cs
index f571355..a360f0f 100644
--- a/src/WebContent.Tests/ProxyForwardCodecTests.cs
+++ b/src/WebContent.Tests/ProxyForwardCodecTests.cs
@@ -68,6 +68,42 @@ public void RecognizesConnectRequestsByTheirAuthorityTarget()
Assert.Null(request.Uri);
}
+ [Fact]
+ public void BuildsOutboundRequestHeadersKeepingCookieAuthorizationAndCustomAppHeaders()
+ {
+ // #55 AC2: request headers pass through unmodified — the plugin is a pure passthrough
+ // for authentication. Only headers describing the browser's connection to THIS proxy
+ // (hop-by-hop, Host, Content-Length the fetcher restates) are dropped.
+ var head =
+ "POST http://10.0.0.5:8080/api/login HTTP/1.1\r\n" +
+ "Host: 10.0.0.5:8080\r\n" +
+ "Cookie: sid=xyz; theme=dark\r\n" +
+ "Authorization: Bearer t-999\r\n" +
+ "Content-Type: application/json\r\n" +
+ "X-Vendor-Session: s1\r\n" +
+ "Accept-Language: en-US\r\n" +
+ "Connection: keep-alive\r\n" +
+ "Proxy-Connection: keep-alive\r\n" +
+ "Content-Length: 17\r\n" +
+ "\r\n";
+
+ Assert.True(ProxyForwardCodec.TryParseRequestHead(head, out var request, out var rejectReason), rejectReason);
+
+ var outbound = ProxyForwardCodec.ToOutboundRequestHeaders(request);
+
+ Assert.Contains(outbound, h => h.Name == "Cookie" && h.Value == "sid=xyz; theme=dark");
+ Assert.Contains(outbound, h => h.Name == "Authorization" && h.Value == "Bearer t-999");
+ Assert.Contains(outbound, h => h.Name == "X-Vendor-Session" && h.Value == "s1");
+ Assert.Contains(outbound, h => h.Name == "Content-Type" && h.Value == "application/json");
+ Assert.Contains(outbound, h => h.Name == "Accept-Language" && h.Value == "en-US");
+
+ // Connection-describing headers are the fetcher's own to restate at the target.
+ Assert.DoesNotContain(outbound, h => h.Name == "Host");
+ Assert.DoesNotContain(outbound, h => h.Name == "Content-Length");
+ Assert.DoesNotContain(outbound, h => h.Name == "Connection");
+ Assert.DoesNotContain(outbound, h => h.Name == "Proxy-Connection");
+ }
+
[Fact]
public void RendersForwardedResponseWithStatusAndContentHeaders()
{
diff --git a/src/WebContent.Tests/ProxyForwardConnectionTests.cs b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
index eb9b9d3..9ff37cd 100644
--- a/src/WebContent.Tests/ProxyForwardConnectionTests.cs
+++ b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
@@ -11,6 +11,8 @@ namespace WebContent.Tests
/// Ticket #54: the connection-handling flow over a byte stream — read the request head, run
/// the decision table, fetch or refuse, render the answer. The stream stands in for the
/// loopback socket; the TcpListener accept loop around it is host-coupled and verified live.
+ /// Since #55 the flow also reads the request body per Content-Length and carries method,
+ /// headers, and body to the fetch seam, and relays Set-Cookie/3xx back untouched.
///
public class ProxyForwardConnectionTests
{
@@ -20,9 +22,9 @@ public class ProxyForwardConnectionTests
public async Task ForwardsAllowlistedGetAndRelaysTheFetchedPage()
{
var requests = new List();
- Func> fetch = url =>
+ Func> fetch = query =>
{
- requests.Add(url);
+ requests.Add(query.Uri.AbsoluteUri);
return Task.FromResult(new ProxyFetchResult
{
Success = true,
@@ -42,51 +44,79 @@ public async Task ForwardsAllowlistedGetAndRelaysTheFetchedPage()
}
[Fact]
- public async Task RefusesNonAllowlistedNavigationWith403WithoutTouchingTheFetcher()
+ public async Task RelaysPostMethodFormBodyAndBrowserRequestHeadersToTheFetcher()
{
- var fetchCalled = false;
- Func> fetch = url =>
+ // #55 AC1/AC2: a vendor login POST crosses the seam with method, body, and the
+ // browser's Cookie header intact; the target's 302 + Set-Cookie come back untouched.
+ ProxyFetchQuery seen = null;
+ Func> fetch = query =>
{
- fetchCalled = true;
- return Task.FromResult(new ProxyFetchResult { Success = false });
+ seen = query;
+ return Task.FromResult(new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 302,
+ StatusText = "Found",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Location", Value = "/dashboard" },
+ new ProxyFetchHeader { Name = "Set-Cookie", Value = "SID=abc123; Path=/; HttpOnly" },
+ },
+ BodyLength = 0,
+ Body = new byte[0],
+ });
};
- var response = await Handle("GET http://elsewhere.test/page HTTP/1.1\r\n\r\n", fetch);
-
- Assert.False(fetchCalled);
- Assert.StartsWith("HTTP/1.1 403 Forbidden\r\n", response);
- Assert.Contains("elsewhere.test", response);
- }
+ var form = "user=admin&pass=x&go=1";
+ var request =
+ "POST http://10.0.0.5:8080/login HTTP/1.1\r\n" +
+ "Host: 10.0.0.5:8080\r\n" +
+ "Content-Type: application/x-www-form-urlencoded\r\n" +
+ "Cookie: sid=keepme\r\n" +
+ $"Content-Length: {form.Length}\r\n" +
+ "\r\n" +
+ form;
- [Fact]
- public async Task SurfacesUnreachableFetchFailuresAs502WithTheFetchersReason()
- {
- Func> fetch = url => Task.FromResult(new ProxyFetchResult
- {
- Success = false,
- ErrorCode = ProxyFetchErrorCodes.Unreachable,
- ErrorMessage = "10.0.0.5:8080: no route to host",
- });
+ var response = await Handle(request, fetch);
- var response = await Handle("GET http://10.0.0.5:8080/gone HTTP/1.1\r\n\r\n", fetch);
+ Assert.Equal("POST", seen.Method);
+ Assert.Equal("http://10.0.0.5:8080/login", seen.Uri.AbsoluteUri);
+ Assert.Equal(Encoding.UTF8.GetBytes(form), seen.Body);
+ Assert.Contains(seen.Headers, h => h.Name == "Cookie" && h.Value == "sid=keepme");
+ Assert.Contains(seen.Headers, h => h.Name == "Content-Type");
+ Assert.DoesNotContain(seen.Headers, h => h.Name == "Host");
- Assert.StartsWith("HTTP/1.1 502 Bad Gateway\r\n", response);
- Assert.Contains("no route to host", response);
+ // AC3 half: the session cookie rides back to the browser on the redirect.
+ Assert.StartsWith("HTTP/1.1 302 Found\r\n", response);
+ Assert.Contains("Location: /dashboard\r\n", response);
+ Assert.Contains("Set-Cookie: SID=abc123; Path=/; HttpOnly\r\n", response);
}
[Fact]
- public async Task MapsFetchTimeoutsTo504()
+ public async Task RelaysEverySetCookieValueAsItsOwnHeaderLine()
{
- Func> fetch = url => Task.FromResult(new ProxyFetchResult
+ // Cookie round-trip hazard: joined Set-Cookie values corrupt the session. The proxy
+ // must render each captured value as its own header line.
+ Func> fetch = query => Task.FromResult(new ProxyFetchResult
{
- Success = false,
- ErrorCode = ProxyFetchErrorCodes.Timeout,
- ErrorMessage = "no response headers within 5000 ms",
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Set-Cookie", Value = "SID=a; Path=/" },
+ new ProxyFetchHeader { Name = "Set-Cookie", Value = "CSRF=b; Path=/; Secure" },
+ new ProxyFetchHeader { Name = "Content-Type", Value = "text/html" },
+ },
+ BodyLength = 2,
+ Body = Encoding.UTF8.GetBytes("ok"),
});
- var response = await Handle("GET http://10.0.0.5:8080/slow HTTP/1.1\r\n\r\n", fetch);
+ var response = await Handle("GET http://10.0.0.5:8080/ HTTP/1.1\r\n\r\n", fetch);
- Assert.StartsWith("HTTP/1.1 504 Gateway Timeout\r\n", response);
+ Assert.Equal(2, CountLines(response, "Set-Cookie: "));
+ Assert.Contains("Set-Cookie: SID=a; Path=/\r\n", response);
+ Assert.Contains("Set-Cookie: CSRF=b; Path=/; Secure\r\n", response);
}
[Fact]
@@ -105,7 +135,7 @@ public async Task RelaysTheFramedChunkStreamThroughCorrelationIntoTheForwardedRe
Body = Encoding.UTF8.GetBytes("body{red}"),
};
- Func> fetch = url =>
+ Func> fetch = query =>
{
var correlation = new ProxyFetchCorrelation();
var completion = correlation.Begin("exchange-1", TimeSpan.FromSeconds(5));
@@ -125,7 +155,96 @@ public async Task RelaysTheFramedChunkStreamThroughCorrelationIntoTheForwardedRe
Assert.Contains("\r\n\r\nbody{red}", response);
}
- private static async Task Handle(string request, Func> fetch)
+ [Fact]
+ public async Task RefusesNonAllowlistedNavigationWith403WithoutTouchingTheFetcher()
+ {
+ var fetchCalled = false;
+ Func> fetch = query =>
+ {
+ fetchCalled = true;
+ return Task.FromResult(new ProxyFetchResult { Success = false });
+ };
+
+ var response = await Handle("GET http://elsewhere.test/page HTTP/1.1\r\n\r\n", fetch);
+
+ Assert.False(fetchCalled);
+ Assert.StartsWith("HTTP/1.1 403 Forbidden\r\n", response);
+ Assert.Contains("elsewhere.test", response);
+ }
+
+ [Fact]
+ public async Task RefusesOversizedRequestBodyByDeclarationWithoutTouchingTheFetcher()
+ {
+ // A declared Content-Length past the proxy's request-body cap is refused before any
+ // body bytes are taken from the socket.
+ var fetchCalled = false;
+ Func> fetch = query =>
+ {
+ fetchCalled = true;
+ return Task.FromResult(new ProxyFetchResult { Success = false });
+ };
+
+ var oversized = ProxyForwardConnection.MaxRequestBodyBytes + 1;
+ var response = await Handle(
+ $"POST http://10.0.0.5:8080/upload HTTP/1.1\r\nContent-Length: {oversized}\r\n\r\n",
+ fetch);
+
+ Assert.False(fetchCalled);
+ Assert.StartsWith("HTTP/1.1 413", response);
+ }
+
+ [Fact]
+ public async Task RefusesChunkedRequestBodiesWith411WithoutTouchingTheFetcher()
+ {
+ // Browsers put a Content-Length on form/XHR bodies; a chunked request body has no
+ // declared size to bound, and the proxy refuses it rather than buffering endlessly.
+ var fetchCalled = false;
+ Func> fetch = query =>
+ {
+ fetchCalled = true;
+ return Task.FromResult(new ProxyFetchResult { Success = false });
+ };
+
+ var response = await Handle(
+ "POST http://10.0.0.5:8080/stream HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n",
+ fetch);
+
+ Assert.False(fetchCalled);
+ Assert.StartsWith("HTTP/1.1 411", response);
+ }
+
+ [Fact]
+ public async Task SurfacesUnreachableFetchFailuresAs502WithTheFetchersReason()
+ {
+ Func> fetch = query => Task.FromResult(new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Unreachable,
+ ErrorMessage = "10.0.0.5:8080: no route to host",
+ });
+
+ var response = await Handle("GET http://10.0.0.5:8080/gone HTTP/1.1\r\n\r\n", fetch);
+
+ Assert.StartsWith("HTTP/1.1 502 Bad Gateway\r\n", response);
+ Assert.Contains("no route to host", response);
+ }
+
+ [Fact]
+ public async Task MapsFetchTimeoutsTo504()
+ {
+ Func> fetch = query => Task.FromResult(new ProxyFetchResult
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Timeout,
+ ErrorMessage = "no response headers within 5000 ms",
+ });
+
+ var response = await Handle("GET http://10.0.0.5:8080/slow HTTP/1.1\r\n\r\n", fetch);
+
+ Assert.StartsWith("HTTP/1.1 504 Gateway Timeout\r\n", response);
+ }
+
+ private static async Task Handle(string request, Func> fetch)
{
using (var input = new MemoryStream(Encoding.UTF8.GetBytes(request)))
using (var output = new MemoryStream())
@@ -134,5 +253,18 @@ private static async Task Handle(string request, Func= 0)
+ {
+ count++;
+ index += prefix.Length;
+ }
+
+ return count;
+ }
}
}
diff --git a/src/WebContent.Tests/ProxyForwardPolicyTests.cs b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
index 6e40643..833dd2e 100644
--- a/src/WebContent.Tests/ProxyForwardPolicyTests.cs
+++ b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
@@ -36,17 +36,50 @@ public void RefusesNonAllowlistedOriginsEvenForAddressBarNavigation()
Assert.Contains("somewhere.else.test", rejectReason);
}
+ [Theory]
+ [InlineData("POST")]
+ [InlineData("PUT")]
+ [InlineData("DELETE")]
+ [InlineData("OPTIONS")]
+ [InlineData("PATCH")]
+ [InlineData("HEAD")]
+ public void ForwardsWriteMethodsToAllowlistedOrigins(string method)
+ {
+ // #55: vendor login flows are POSTs with form bodies; background XHR uses PUT/DELETE/
+ // OPTIONS/PATCH. The ES fetcher replays them, so the forwarder no longer refuses them.
+ var head = $"{method} http://10.0.0.5:8080/login HTTP/1.1\r\nContent-Length: 15\r\nContent-Type: application/json\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.Forward, decision);
+ Assert.Equal(method, request.Method);
+ Assert.Equal("15", request.Headers["Content-Length"]);
+ Assert.Null(rejectReason);
+ }
+
[Fact]
- public void RefusesMethodsWithBodyInsteadOfForwardingThem()
+ public void RefusesUnknownMethodsWith405InsteadOfForwardingThem()
{
- // The Event Server fetcher (#53) is GET-only; a POST must be refused locally with an
- // explicit 405, never quietly forwarded and never answered from the client machine.
- var head = "POST http://10.0.0.5:8080/login HTTP/1.1\r\nContent-Length: 12\r\n\r\nuser=x&pwd=y";
+ // The method allowlist is explicit: TRACE and junk verbs stay refused, never relayed.
+ var head = "TRACE http://10.0.0.5:8080/debug HTTP/1.1\r\n\r\n";
var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
Assert.Equal(ProxyForwardDisposition.MethodNotAllowed, decision);
- Assert.Contains("POST", rejectReason);
+ Assert.Contains("TRACE", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesWriteMethodsToNonAllowlistedOriginsWith403()
+ {
+ // The origin gate runs for POST exactly as for GET — a POST to an origin no proxied
+ // workspace allowlists never reaches the fetcher.
+ var head = "POST http://elsewhere.test/login HTTP/1.1\r\nContent-Length: 5\r\n\r\nx=1";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.OriginNotAllowed, decision);
+ Assert.Contains("elsewhere.test", rejectReason);
}
[Fact]
diff --git a/src/WebContent/Client/BrowserManager.cs b/src/WebContent/Client/BrowserManager.cs
index a490ca3..b6ba7ea 100644
--- a/src/WebContent/Client/BrowserManager.cs
+++ b/src/WebContent/Client/BrowserManager.cs
@@ -154,9 +154,10 @@ private static string ProxiedUserDataFolder(Guid id)
///
/// The listener's fetch seam: proxy-fetch over classic MIP messaging to the Event Server
- /// fetcher (#53). An unconnected channel is an explicit fetch failure, never a direct hit.
+ /// fetcher (#53; method/headers/body since #55). An unconnected channel is an explicit
+ /// fetch failure, never a direct hit.
///
- private static Task FetchThroughMessaging(string url)
+ private static Task FetchThroughMessaging(ProxyFetchQuery query)
{
var client = ProxyFetchClient.Active;
if (client == null || !client.IsReady)
@@ -169,7 +170,7 @@ private static Task FetchThroughMessaging(string url)
});
}
- return client.FetchAsync(url);
+ return client.FetchAsync(query);
}
///
diff --git a/src/WebContent/Client/LoopbackProxyListener.cs b/src/WebContent/Client/LoopbackProxyListener.cs
index 515d924..7515e3a 100644
--- a/src/WebContent/Client/LoopbackProxyListener.cs
+++ b/src/WebContent/Client/LoopbackProxyListener.cs
@@ -19,14 +19,14 @@ namespace WebContent.Client
///
internal sealed class LoopbackProxyListener
{
- private readonly Func> _fetch;
+ private readonly Func> _fetch;
private IReadOnlyList _canonicalTargets;
private TcpListener _listener;
private volatile bool _stopping;
internal int Port { get; private set; }
- internal LoopbackProxyListener(IReadOnlyList canonicalTargets, Func> fetch)
+ internal LoopbackProxyListener(IReadOnlyList canonicalTargets, Func> fetch)
{
_canonicalTargets = canonicalTargets ?? new string[0];
_fetch = fetch ?? throw new ArgumentNullException(nameof(fetch));
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
index d4853f5..6ab2685 100644
--- a/src/WebContent/ProxyFetchBackgroundPlugin.cs
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -144,6 +144,40 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
return null;
}
+ // #55: method and body decode against the same verb table the client forwarder uses,
+ // so a crafted or version-skewed payload can never drive the VMS server with an
+ // arbitrary verb. A null method is a pre-#55 GET sender.
+ var method = string.IsNullOrWhiteSpace(payload.Method) ? "GET" : payload.Method.Trim().ToUpperInvariant();
+ if (!ProxyForwardPolicy.IsForwardedMethod(method))
+ {
+ LogRefused(payload, $"'{method}' is outside the relayed method set");
+ TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, $"The proxy does not relay '{method}' requests.");
+ return null;
+ }
+
+ byte[] body = null;
+ if (!string.IsNullOrEmpty(payload.BodyBase64))
+ {
+ try
+ {
+ body = Convert.FromBase64String(payload.BodyBase64);
+ }
+ catch (FormatException)
+ {
+ LogRefused(payload, "the request body was not valid base64");
+ TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch request body was not valid base64.");
+ return null;
+ }
+ }
+
+ var query = new ProxyFetchQuery
+ {
+ Uri = target,
+ Method = method,
+ Headers = payload.Headers,
+ Body = body,
+ };
+
string allowReason = null;
if (_allowlist == null || !_allowlist.IsAllowed(target, out allowReason))
{
@@ -177,7 +211,7 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
try
{
- await ServeFetchAsync(communication, payload, target, requester);
+ await ServeFetchAsync(communication, payload, query, requester);
}
finally
{
@@ -188,12 +222,13 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
return null;
}
- private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetchRequestPayload payload, Uri target, FQID requesterFqid)
+ private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetchRequestPayload payload, ProxyFetchQuery query, FQID requesterFqid)
{
+ var target = query.Uri;
try
{
var limits = ProxyFetchLimits.Default();
- var result = await new ProxyHttpFetcher().FetchAsync(target, limits);
+ var result = await new ProxyHttpFetcher().FetchAsync(query, limits);
if (!result.Success)
{
diff --git a/src/WebContent/ProxyFetchClient.cs b/src/WebContent/ProxyFetchClient.cs
index 813294d..4227221 100644
--- a/src/WebContent/ProxyFetchClient.cs
+++ b/src/WebContent/ProxyFetchClient.cs
@@ -120,10 +120,26 @@ internal void Close()
}
///
- /// Send one proxy-fetch request and return the task completed with the reassembled
- /// outcome. The deadline bounds the whole exchange, not each frame.
+ /// Send one GET proxy-fetch request (the Management Client harness path, #53). The
+ /// browser-facing flows use the full overload below.
///
internal Task FetchAsync(string url, TimeSpan? deadline = null)
+ {
+ Uri target;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out target))
+ {
+ return Task.FromResult(Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri."));
+ }
+
+ return FetchAsync(new ProxyFetchQuery { Uri = target, Method = "GET" }, deadline);
+ }
+
+ ///
+ /// Send one proxy-fetch request and return the task completed with the reassembled
+ /// outcome. The deadline bounds the whole exchange, not each frame. Since #55 the method,
+ /// request headers, and body ride along — the JSON-string payload stays one string.
+ ///
+ internal Task FetchAsync(ProxyFetchQuery query, TimeSpan? deadline = null)
{
var communication = _communication;
if (communication == null)
@@ -131,8 +147,8 @@ internal Task FetchAsync(string url, TimeSpan? deadline = null
return Task.FromResult(Failure(ProxyFetchErrorCodes.Error, "The proxy fetch harness is not connected to the Event Server."));
}
- Uri target;
- if (!Uri.TryCreate(url, UriKind.Absolute, out target) || !ProxyPolicy.IsHttpUri(target))
+ Uri target = query == null ? null : query.Uri;
+ if (!ProxyPolicy.IsHttpUri(target))
{
return Task.FromResult(Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri."));
}
@@ -156,6 +172,9 @@ internal Task FetchAsync(string url, TimeSpan? deadline = null
{
CorrelationId = correlationId,
Url = target.AbsoluteUri,
+ Method = string.IsNullOrWhiteSpace(query.Method) ? null : query.Method.Trim().ToUpperInvariant(),
+ Headers = query.Headers != null && query.Headers.Count > 0 ? query.Headers : null,
+ BodyBase64 = query.Body != null && query.Body.Length > 0 ? Convert.ToBase64String(query.Body) : null,
})),
null,
null,
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index dedbb75..aae3456 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -80,13 +80,27 @@ public class ProxyFetchHeader
///
/// Request payload: one correlation id per request so the requester can match the response
- /// frames that come back over the shared message channel.
+ /// frames that come back over the shared message channel. Since #55 it also carries the HTTP
+ /// method, the browser's request headers, and a base64 request body — the proxy is a pure
+ /// passthrough for authentication (Cookie/Authorization cross untouched; the plugin never
+ /// injects or stores target credentials). A null Method means GET; null Headers/BodyBase64
+ /// mean none. Still ONE JSON string on the wire via .
///
[Serializable]
public class ProxyFetchRequestPayload
{
public string CorrelationId;
public string Url;
+
+ /// Uppercase HTTP method (POST/PUT/DELETE/OPTIONS/...); null or blank is GET.
+ public string Method;
+
+ /// Request headers to replay at the target, hop-by-hop headers already stripped
+ /// by the requester side; null when the request carried none worth relaying.
+ public List Headers;
+
+ /// Request body as base64 (login forms, XHR payloads); null when there is no body.
+ public string BodyBase64;
}
///
@@ -128,6 +142,27 @@ public class ProxyFetchResponsePayload
public string ErrorMessage;
}
+ ///
+ /// One browser request the loopback forwarding proxy decided to relay, as it travels the
+ /// client-side seam: listener -> BrowserManager fetch seam -> ProxyFetchClient -> messaging.
+ /// Not a wire payload; is its serialized form. Since
+ /// #55 the method, browser request headers, and request body ride along, so a vendor login
+ /// POST reaches the Event Server fetcher with its credentials and form body intact.
+ ///
+ internal sealed class ProxyFetchQuery
+ {
+ internal Uri Uri;
+
+ /// Uppercase HTTP method; null/blank means GET.
+ internal string Method;
+
+ /// Browser request headers to replay at the target, hop-by-hop ones stripped.
+ internal List Headers = new List();
+
+ /// Request body bytes; null when the request carried no body.
+ internal byte[] Body;
+ }
+
///
/// The fetcher's own outcome model, shared by the server-side fetcher, the framing seam, and
/// the requester-side reassembly. Not a message payload: the framer serializes it into frames,
diff --git a/src/WebContent/ProxyFetchTransport.cs b/src/WebContent/ProxyFetchTransport.cs
index 0f73682..2073f3a 100644
--- a/src/WebContent/ProxyFetchTransport.cs
+++ b/src/WebContent/ProxyFetchTransport.cs
@@ -8,32 +8,68 @@
namespace WebContent
{
///
- /// Server-side transport for the proxy-fetch contract (#53): performs one HTTP(S) GET from
- /// the server network and enforces the ticket's bounds — a connect budget, a per-read stall
+ /// Server-side transport for the proxy-fetch contract (#53; write verbs, request headers,
+ /// and request bodies since #55): replays one proxied browser request
+ /// from the server network and enforces the ticket's bounds — a connect budget, a per-read stall
/// budget, and a maximum response size — independent of which runtime hosts the plugin (the
/// .NET Framework HttpWebRequest timeouts double as native bounds, but the explicit
/// task-budget races below make the behavior identical everywhere).
///
/// A target HTTP error status (404 and friends) is still a successful fetch: the proxy
- /// relays the target's status line, headers, and page.
+ /// relays the target's status line, headers, and page. So is a 3xx: redirects and their
+ /// Set-Cookie headers ride back to the browser untouched (#55 — the session cookie lives in
+ /// the browser, never in the VMS server: UseCookies and AllowAutoRedirect are both off).
///
internal sealed class ProxyHttpFetcher
{
///
- /// GET the target under the given limits. Never throws: transport outcomes are reported in
+ /// Replay the request carried by the query under the given limits. Never throws: transport
+ /// outcomes are reported in
/// the returned ProxyFetchResult so framing/logging can react to each failure class.
///
- internal async Task FetchAsync(Uri target, ProxyFetchLimits limits)
+ internal async Task FetchAsync(ProxyFetchQuery query, ProxyFetchLimits limits)
{
+ var target = query == null ? null : query.Uri;
if (!ProxyPolicy.IsHttpUri(target))
{
return Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch target must be an http(s) url.");
}
var request = (HttpWebRequest)WebRequest.Create(target);
- request.Method = "GET";
+ request.Method = string.IsNullOrWhiteSpace(query.Method) ? "GET" : query.Method.Trim().ToUpperInvariant();
request.UserAgent = "CascadiaWebContentPlugin/MIP-proxy-fetcher";
+ // #55 passthrough rules: the browser owns the session. A 3xx and its Set-Cookie values
+ // relay back to the browser instead of being followed or stored here, so no target
+ // credential ever persists on the VMS server. UseCookies only exists on the .NET
+ // Framework host (and matters there: it auto-creates a CookieContainer that would
+ // swallow Set-Cookie); the compat shim is cookie-less by construction.
+ request.AllowAutoRedirect = false;
+ try
+ {
+ var useCookies = typeof(HttpWebRequest).GetProperty("UseCookies");
+ if (useCookies != null && useCookies.CanWrite)
+ {
+ useCookies.SetValue(request, false);
+ }
+ }
+ catch (Exception)
+ {
+ // Property may be unsupported or read-only on compat stacks.
+ }
+
+ try
+ {
+ // Vendor device servers often stall ~3 s waiting for it; browsers only send it
+ // when they choose to, and that choice is relayed as a header instead.
+ request.ServicePoint.Expect100Continue = false;
+ }
+ catch (Exception)
+ {
+ }
+
+ ApplyRequestHeaders(request, query.Headers);
+
// On the .NET Framework host these are native enforcement; elsewhere the explicit
// budgets below enforce the same bounds. The properties sit a full second past the
// budgets so native enforcement can never race (and pre-empt) the budgeted checks.
@@ -47,6 +83,15 @@ internal async Task FetchAsync(Uri target, ProxyFetchLimits li
// Property may be unsupported on compat stacks; the budgets cover enforcement.
}
+ if (query.Body != null && query.Body.Length > 0)
+ {
+ var writeFailure = await WriteRequestBodyAsync(request, query.Body, target, limits);
+ if (writeFailure != null)
+ {
+ return writeFailure;
+ }
+ }
+
HttpWebResponse response;
try
{
@@ -101,6 +146,118 @@ internal async Task FetchAsync(Uri target, ProxyFetchLimits li
}
}
+ ///
+ /// #55: replay the browser's request headers on the fetcher's own request. Property-backed
+ /// headers go through their HttpWebRequest properties, connection-level headers are the
+ /// fetcher's own to state (never relayed from the wire — a crafted payload cannot make the
+ /// VMS server speak hop-by-hop verbs at a target), and .NET Framework header restrictions
+ /// degrade to a skip, never a throw.
+ ///
+ private static void ApplyRequestHeaders(HttpWebRequest request, List headers)
+ {
+ if (headers == null)
+ {
+ return;
+ }
+
+ foreach (var header in headers)
+ {
+ if (header == null || string.IsNullOrEmpty(header.Name) || header.Value == null)
+ {
+ continue;
+ }
+
+ switch (header.Name.ToUpperInvariant())
+ {
+ case "USER-AGENT":
+ request.UserAgent = header.Value;
+ break;
+ case "REFERER":
+ request.Referer = header.Value;
+ break;
+ case "CONTENT-TYPE":
+ request.ContentType = header.Value;
+ break;
+ default:
+ if (TransportBlockedRequestHeaders.Contains(header.Name))
+ {
+ break;
+ }
+
+ try
+ {
+ request.Headers[header.Name] = header.Value;
+ }
+ catch (Exception)
+ {
+ // Restricted or malformed header names on the host stack: skipping the
+ // single header beats failing the whole fetch.
+ }
+
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Request headers the fetcher never replays regardless of what arrived on the wire: they
+ /// describe a connection or body framing the fetcher states itself. Cookie, Authorization,
+ /// Content-Type (property), Accept-*, and custom app headers deliberately pass (#55).
+ ///
+ private static readonly HashSet TransportBlockedRequestHeaders =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Host",
+ "Content-Length",
+ "Connection",
+ "Keep-Alive",
+ "Proxy-Connection",
+ "Proxy-Authenticate",
+ "Proxy-Authorization",
+ "TE",
+ "Trailer",
+ "Transfer-Encoding",
+ "Upgrade",
+ "Expect",
+ };
+
+ private static async Task WriteRequestBodyAsync(HttpWebRequest request, byte[] body, Uri target, ProxyFetchLimits limits)
+ {
+ request.ContentLength = body.Length;
+ try
+ {
+ var streamTask = request.GetRequestStreamAsync();
+ var winner = await Task.WhenAny(streamTask, Task.Delay(limits.ConnectTimeoutMs));
+ if (winner != streamTask)
+ {
+ try
+ {
+ request.Abort();
+ }
+ catch (Exception)
+ {
+ }
+
+ return Failure(ProxyFetchErrorCodes.Timeout, $"Could not start sending the request body to {ProxyPolicy.CanonicalOrigin(target)} within {limits.ConnectTimeoutMs} ms.");
+ }
+
+ using (var requestStream = await streamTask)
+ {
+ await requestStream.WriteAsync(body, 0, body.Length);
+ }
+
+ return null;
+ }
+ catch (WebException webException)
+ {
+ return MapWebException(webException, target, limits);
+ }
+ catch (Exception exception)
+ {
+ return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: request body not sent: {exception.Message}");
+ }
+ }
+
private static async Task ReadCappedBodyAsync(HttpWebResponse response, Uri target, ProxyFetchLimits limits)
{
var result = new ProxyFetchResult
@@ -162,7 +319,23 @@ private static List ReadHeaders(HttpWebResponse response)
var headers = new List();
foreach (var name in response.Headers.AllKeys)
{
- headers.Add(new ProxyFetchHeader { Name = name, Value = response.Headers[name] });
+ // One entry per raw value (#55): indexer access joins repeated values with ", ",
+ // which corrupts repeated Set-Cookie headers (and the comma inside their Expires
+ // dates) — a session cookie that fails to round-trip kills vendor logins.
+ string[] values;
+ try
+ {
+ values = response.Headers.GetValues(name);
+ }
+ catch (Exception)
+ {
+ values = new[] { response.Headers[name] };
+ }
+
+ foreach (var value in values ?? new[] { response.Headers[name] })
+ {
+ headers.Add(new ProxyFetchHeader { Name = name, Value = value });
+ }
}
// Content-Type/Content-Encoding live on the response object itself and may be missing
diff --git a/src/WebContent/ProxyForwardCodec.cs b/src/WebContent/ProxyForwardCodec.cs
index 9d5279c..b4e2310 100644
--- a/src/WebContent/ProxyForwardCodec.cs
+++ b/src/WebContent/ProxyForwardCodec.cs
@@ -7,8 +7,9 @@ namespace WebContent
{
///
/// One proxied request the browser sent to the loopback forwarding proxy (#54), as parsed
- /// from the absolute-form request line plus headers. Bodies are never read: the ES fetcher
- /// is GET-only, so any method with a body is refused before the body arrives.
+ /// from the absolute-form request line plus headers. The body itself is read separately by
+ /// per Content-Length (#55) and travels to the fetcher
+ /// on .
///
internal sealed class ProxyForwardedRequest
{
@@ -120,6 +121,49 @@ internal static bool TryParseRequestHead(string head, out ProxyForwardedRequest
"Content-Length",
};
+ ///
+ /// Browser request headers the fetcher must NOT replay at the target (#55): the hop-by-hop
+ /// set plus Host (the fetcher's HttpWebRequest derives it from the uri) and Content-Length
+ /// (restated from the relayed body). Everything else — Cookie, Authorization, Content-Type,
+ /// Accept-*, custom app verbs' headers — passes through unmodified; the proxy never
+ /// injects or inspects credentials.
+ ///
+ private static readonly HashSet OutboundRequestBlockedHeaders =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Connection",
+ "Keep-Alive",
+ "Proxy-Authenticate",
+ "Proxy-Authorization",
+ "Proxy-Connection",
+ "TE",
+ "Trailer",
+ "Transfer-Encoding",
+ "Upgrade",
+ "Content-Length",
+ "Host",
+ };
+
+ ///
+ /// The request headers to carry on the wire to the Event Server fetcher: the browser's
+ /// headers minus the outbound-blocked set, original casing and values intact.
+ ///
+ internal static List ToOutboundRequestHeaders(ProxyForwardedRequest request)
+ {
+ var outbound = new List();
+ foreach (var header in request.Headers)
+ {
+ if (OutboundRequestBlockedHeaders.Contains(header.Key))
+ {
+ continue;
+ }
+
+ outbound.Add(new ProxyFetchHeader { Name = header.Key, Value = header.Value });
+ }
+
+ return outbound;
+ }
+
///
/// Render a reassembled fetch outcome (AC3: status code and headers intact) as an HTTP/1.1
/// response for the browser connection. Body framing is the proxy's own Content-Length;
diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs
index e1bc7e9..d6f9a27 100644
--- a/src/WebContent/ProxyForwardConnection.cs
+++ b/src/WebContent/ProxyForwardConnection.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Text;
using System.Threading.Tasks;
@@ -7,22 +8,30 @@
namespace WebContent
{
///
- /// One proxied browser connection handled end to end (#54): read the request head from the
- /// stream, run the decision table ( ), fetch allowlisted GETs
- /// through the caller-supplied fetch seam (MIP messaging to the Event Server in production,
- /// a fake in tests), and render the answer back. Head reading is capped so a chatty or hostile
- /// local client cannot pin unbounded memory on the receiver path.
+ /// One proxied browser connection handled end to end (#54; write verbs and request bodies
+ /// since #55): read the request head from the stream, run the decision table
+ /// ( ), read the request body per its declared Content-Length,
+ /// relay allowlisted requests through the caller-supplied fetch seam (MIP messaging to the
+ /// Event Server fetcher in production, a fake in tests), and render the answer back. Head
+ /// reading is capped so a chatty or hostile local client cannot pin unbounded memory, and a
+ /// request body is only buffered when its declared size fits the request cap.
///
internal static class ProxyForwardConnection
{
/// Request heads past this size are refused outright: browser requests are far smaller.
internal const int MaxHeadBytes = 64 * 1024;
+ ///
+ /// Request-body cap (#55): login forms and XHR payloads are kilobytes; a declared body
+ /// past this bound is refused with 413 before any of it is taken from the socket.
+ ///
+ internal const int MaxRequestBodyBytes = 1024 * 1024;
+
internal static async Task HandleAsync(
Stream input,
Stream output,
System.Collections.Generic.IEnumerable canonicalTargets,
- Func> fetch,
+ Func> fetch,
Action log)
{
var head = await ReadRequestHeadAsync(input);
@@ -33,7 +42,7 @@ internal static async Task HandleAsync(
return;
}
- var disposition = ProxyForwardPolicy.Decide(canonicalTargets, head, out var request, out var rejectReason);
+ var disposition = ProxyForwardPolicy.Decide(canonicalTargets, head.Text, out var request, out var rejectReason);
switch (disposition)
{
case ProxyForwardDisposition.BadRequest:
@@ -48,7 +57,7 @@ internal static async Task HandleAsync(
case ProxyForwardDisposition.MethodNotAllowed:
log(rejectReason);
- await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(405, "Method Not Allowed", rejectReason + " The workspace proxy only fetches GET."));
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(405, "Method Not Allowed", rejectReason));
return;
case ProxyForwardDisposition.ConnectRefused:
@@ -57,10 +66,26 @@ internal static async Task HandleAsync(
return;
}
+ // #55: the request body must reach the fetcher for login POSTs and XHR writes to work.
+ var bodyOutcome = await TryResolveRequestBodyAsync(request, head.Leftover, input, log);
+ if (bodyOutcome.RefusalPage != null)
+ {
+ await WriteAsync(output, bodyOutcome.RefusalPage);
+ return;
+ }
+
+ var query = new ProxyFetchQuery
+ {
+ Uri = request.Uri,
+ Method = request.Method,
+ Headers = ProxyForwardCodec.ToOutboundRequestHeaders(request),
+ Body = bodyOutcome.Body,
+ };
+
ProxyFetchResult result;
try
{
- result = await fetch(request.Uri.AbsoluteUri);
+ result = await fetch(query);
}
catch (Exception exception)
{
@@ -87,10 +112,128 @@ internal static async Task HandleAsync(
await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", message));
}
- private static async Task ReadRequestHeadAsync(Stream input)
+ ///
+ /// Decide and read the request body (#55). A chunked body carries no declared size to
+ /// bound, so it is refused (411); a declared length past the cap is refused before any
+ /// body bytes are taken (413); a body that contradicts its declaration is refused (400).
+ /// A refusal comes back as an already-rendered page.
+ ///
+ private static async Task TryResolveRequestBodyAsync(
+ ProxyForwardedRequest request,
+ byte[] leftover,
+ Stream input,
+ Action log)
{
+ string transferEncoding;
+ request.Headers.TryGetValue("Transfer-Encoding", out transferEncoding);
+ if (!string.IsNullOrEmpty(transferEncoding) &&
+ transferEncoding.IndexOf("chunked", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ var reason = $"The proxy requires a Content-Length on request bodies; the chunked body for '{request.RawTarget}' is refused.";
+ log(reason);
+ return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse(411, "Length Required", reason));
+ }
+
+ string contentLengthText;
+ request.Headers.TryGetValue("Content-Length", out contentLengthText);
+ if (string.IsNullOrEmpty(contentLengthText))
+ {
+ return RequestBodyOutcome.Accept(null);
+ }
+
+ int declared;
+ if (!int.TryParse(contentLengthText.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out declared) || declared < 0)
+ {
+ var reason = $"The Content-Length '{contentLengthText}' for '{request.RawTarget}' is not a usable size.";
+ log(reason);
+ return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", reason));
+ }
+
+ if (declared > MaxRequestBodyBytes)
+ {
+ var reason = $"The request body for '{request.RawTarget}' declares {declared} bytes, past the proxy's {MaxRequestBodyBytes} byte request cap.";
+ log(reason);
+ return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse(413, "Payload Too Large", reason));
+ }
+
+ if (declared == 0)
+ {
+ return RequestBodyOutcome.Accept(null);
+ }
+
+ if (leftover.Length > declared)
+ {
+ var reason = $"The request body for '{request.RawTarget}' carries more bytes than its declared Content-Length.";
+ log(reason);
+ return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", reason));
+ }
+
+ var buffer = new byte[declared];
+ var taken = 0;
+ if (leftover.Length > 0)
+ {
+ Buffer.BlockCopy(leftover, 0, buffer, 0, leftover.Length);
+ taken = leftover.Length;
+ }
+
+ while (taken < declared)
+ {
+ var read = await input.ReadAsync(buffer, taken, declared - taken);
+ if (read <= 0)
+ {
+ break;
+ }
+
+ taken += read;
+ }
+
+ if (taken < declared)
+ {
+ var reason = $"The request body for '{request.RawTarget}' ended after {taken} bytes, short of its declared {declared}.";
+ log(reason);
+ return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", reason));
+ }
+
+ return RequestBodyOutcome.Accept(buffer);
+ }
+
+ /// Outcome of request-body resolution: either the bytes or a rendered refusal page.
+ private sealed class RequestBodyOutcome
+ {
+ internal readonly byte[] Body;
+ internal readonly byte[] RefusalPage;
+
+ private RequestBodyOutcome(byte[] body, byte[] refusalPage)
+ {
+ Body = body;
+ RefusalPage = refusalPage;
+ }
+
+ internal static RequestBodyOutcome Accept(byte[] body)
+ {
+ return new RequestBodyOutcome(body, null);
+ }
+
+ internal static RequestBodyOutcome Refuse(byte[] page)
+ {
+ return new RequestBodyOutcome(null, page);
+ }
+ }
+
+ ///
+ /// The parsed request head plus any body bytes the client pipelined into the same read.
+ ///
+ private sealed class RequestHead
+ {
+ internal string Text;
+ internal byte[] Leftover = new byte[0];
+ }
+
+ private static async Task ReadRequestHeadAsync(Stream input)
+ {
+ var head = new RequestHead();
var buffer = new byte[8 * 1024];
- var head = new MemoryStream();
+ var received = new MemoryStream();
while (true)
{
var read = await input.ReadAsync(buffer, 0, buffer.Length);
@@ -99,29 +242,38 @@ private static async Task ReadRequestHeadAsync(Stream input)
break;
}
- head.Write(buffer, 0, read);
- if (ContainsHeadTerminator(head.GetBuffer(), 0, (int)head.Length))
+ received.Write(buffer, 0, read);
+ if (ContainsHeadTerminator(received.GetBuffer(), 0, (int)received.Length))
{
break;
}
- if (head.Length > MaxHeadBytes)
+ if (received.Length > MaxHeadBytes)
{
return null;
}
}
- if (head.Length == 0)
+ if (received.Length == 0)
{
return null;
}
- // Head bytes are cut at the header terminator; request bodies (which the GET-only
- // proxy never forwards) are never read. Latin-1 keeps byte values faithful per RFC 7230.
- var bytes = head.ToArray();
+ // Head bytes are cut at the header terminator; whatever the client pipelined after it
+ // (a POST body that arrived in the same segment) becomes leftover for the body reader
+ // (#55). Latin-1 keeps byte values faithful per RFC 7230.
+ var bytes = received.ToArray();
var terminator = IndexOfHeadTerminator(bytes, 0, bytes.Length);
var textLength = terminator >= 0 ? terminator : bytes.Length;
- return Encoding.GetEncoding("ISO-8859-1").GetString(bytes, 0, textLength);
+ var leftoverOffset = terminator >= 0 ? terminator + TerminatorLength(bytes, terminator) : bytes.Length;
+ if (leftoverOffset < bytes.Length)
+ {
+ head.Leftover = new byte[bytes.Length - leftoverOffset];
+ Buffer.BlockCopy(bytes, leftoverOffset, head.Leftover, 0, head.Leftover.Length);
+ }
+
+ head.Text = Encoding.GetEncoding("ISO-8859-1").GetString(bytes, 0, textLength);
+ return head;
}
private static bool ContainsHeadTerminator(byte[] bytes, int offset, int length)
@@ -149,6 +301,11 @@ private static int IndexOfHeadTerminator(byte[] bytes, int offset, int length)
return -1;
}
+ private static int TerminatorLength(byte[] bytes, int terminator)
+ {
+ return bytes[terminator] == '\r' ? 4 : 2;
+ }
+
private static async Task WriteAsync(Stream output, byte[] response)
{
await output.WriteAsync(response, 0, response.Length);
diff --git a/src/WebContent/ProxyForwardPolicy.cs b/src/WebContent/ProxyForwardPolicy.cs
index 8b0d054..8cc8abf 100644
--- a/src/WebContent/ProxyForwardPolicy.cs
+++ b/src/WebContent/ProxyForwardPolicy.cs
@@ -15,7 +15,7 @@ internal enum ProxyForwardDisposition
/// Origin not in the workspace allowlist (or address-bar navigation to it) — 403.
OriginNotAllowed,
- /// Method other than GET (the ES fetcher is GET-only) — 405.
+ /// Verb outside the browser-flow method set (#55) — 405.
MethodNotAllowed,
/// CONNECT tunnel (https through a CONNECT-less forwarder) — 502.
@@ -23,13 +23,34 @@ internal enum ProxyForwardDisposition
}
///
- /// Pure decision layer for the loopback forwarding proxy (#54): every browser request that
- /// reaches the listener — page document, subresources, address-bar navigation alike — goes
- /// through the same table. Allowlisted GETs forward (via ProxyPolicy from the config ticket
+ /// Pure decision layer for the loopback forwarding proxy (#54, write verbs since #55): every
+ /// browser request that reaches the listener — page document, subresources, login-form POSTs,
+ /// background XHR, address-bar navigation alike — goes through the same table. Allowlisted
+ /// requests in the browser-flow method set forward (via ProxyPolicy from the config ticket
/// #52); everything else is refused with an explicit status, never sent direct.
///
internal static class ProxyForwardPolicy
{
+ ///
+ /// Verbs the proxy relays (#55): the set a browser page actually generates — document GET,
+ /// vendor login POSTs, XHR PUT/DELETE/PATCH, CORS preflight OPTIONS. CONNECT is refused as
+ /// a tunnel, not treated as a verb; TRACE and other verbs are refused (405), never relayed.
+ ///
+ private static readonly HashSet ForwardedMethods =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH",
+ };
+
+ ///
+ /// True for the verbs the whole proxy chain relays. The Event Server fetcher re-checks
+ /// decoded request payloads against this same set, so client and server agree on one table.
+ ///
+ internal static bool IsForwardedMethod(string method)
+ {
+ return !string.IsNullOrWhiteSpace(method) && ForwardedMethods.Contains(method);
+ }
+
internal static ProxyForwardDisposition Decide(
IEnumerable canonicalTargets,
string requestHead,
@@ -47,9 +68,9 @@ internal static ProxyForwardDisposition Decide(
return ProxyForwardDisposition.ConnectRefused;
}
- if (request.Method != "GET")
+ if (!ForwardedMethods.Contains(request.Method))
{
- rejectReason = $"The proxy fetcher is GET-only; '{request.Method}' to '{request.RawTarget}' is refused.";
+ rejectReason = $"The proxy relays browser-flow verbs (GET/HEAD/POST/PUT/DELETE/OPTIONS/PATCH); '{request.Method}' to '{request.RawTarget}' is refused.";
return ProxyForwardDisposition.MethodNotAllowed;
}
From 7c42d9e233031f97e57fa3a51a56158841c3ecd6 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 10:26:57 -0700
Subject: [PATCH 009/187] fix(proxy): review follow-ups for #55 - transport
property setters cannot throw, wire values CR/LF-gated; Event Server re-caps
request body size; source guards pin request-side payload assembly and ES
method/base64/cap re-checks; stale-comment and version-skew doc corrections
---
docs/verification-matrix.md | 10 +-
.../ProxyFetchReplyGuardTests.cs | 28 ++
src/WebContent/ProxyFetchBackgroundPlugin.cs | 10 +
src/WebContent/ProxyFetchTransport.cs | 57 ++--
src/WebContent/ProxyForwardCodec.cs | 5 +-
src/WebContent/ProxyForwardConnection.cs | 4 +-
.../Harness/LoginTestServer.cs | 302 ++++++++++++++++++
.../ScProxyLoginProbeTests.cs | 212 ++++++++++++
8 files changed, 598 insertions(+), 30 deletions(-)
create mode 100644 tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
create mode 100644 tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index bff2709..df47c4d 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -193,9 +193,11 @@ device or any http page — https is refused by design, see below).
### SC-2 Vendor login flow through the proxy (#55)
-Requires the same both-machine drop as SC-1 (the #55 wire contract carries method/headers/body,
-so client and Event Server MUST run the same build — deploys are simultaneous or every payload
-decodes as an unusable frame).
+Requires the same both-machine drop as SC-1. The #55 wire contract carries method/headers/body,
+so client and Event Server MUST run the same build — DataContractJsonSerializer ignores unknown
+members, so a skewed pair does NOT fail loudly: a new client against an old Event Server silently
+degrades every POST to a body-less GET (the login loops back to the form). Deploys are
+simultaneous.
1. Allowlist an http target that serves a login form: a real camera page, or the probe login
server (`tests/ux/WebContent.UxTests`, `Category=probe` serves `/login` → POST → 302 +
@@ -206,6 +208,8 @@ decodes as an unusable frame).
signed-in state, not the form again.
3. The session cookie lives ONLY in the WebView2 profile: no cookie store exists on the Event
Server side (`UseCookies`/`AllowAutoRedirect` off — pinned by `ProxyFetchTransportTests`).
+ A login form that submits with a chunked body (modern `fetch()` + `ReadableStream` style —
+ ordinary forms and XHR always carry Content-Length) answers 411 "Length Required" by design.
4. Automatable harness: extend `ScProxyFetchProbeTests` (probe category) — artifacts under
`%TEMP%\webcontent-sc-proxy-probe\`; the probe goes red when the dashboard is not
reached or the cookie round-trip fails.
diff --git a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
index b7126d8..8c8ce2b 100644
--- a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
+++ b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
@@ -74,5 +74,33 @@ public void BothSidesCarryPayloadsAsJsonStringsNeverAsPluginTypes()
Assert.Contains("ProxyFetchWireCodec.FrameFromWire(message?.Data)", client);
Assert.DoesNotContain("message?.Data as ProxyFetchResponsePayload", client);
}
+
+ [Fact]
+ public void RequestSideAssemblesMethodHeadersAndBodyOntoTheWirePayload()
+ {
+ // The codec round-trip starts AFTER this assembly step, so nothing else pins that the
+ // browser's verb, headers, and body actually ride the transmitted payload (#55).
+ // Lose one assignment and the POST silently degrades to a body-less GET on the wire.
+ var client = File.ReadAllText(Path.Combine(
+ RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyFetchClient.cs"));
+
+ var collapsed = System.Text.RegularExpressions.Regex.Replace(client, @"\s+", " ");
+ Assert.Contains("Method = string.IsNullOrWhiteSpace(query.Method) ? null : query.Method.Trim().ToUpperInvariant(),", collapsed);
+ Assert.Contains("Headers = query.Headers != null && query.Headers.Count > 0 ? query.Headers : null,", collapsed);
+ Assert.Contains("BodyBase64 = query.Body != null && query.Body.Length > 0 ? Convert.ToBase64String(query.Body) : null,", collapsed);
+ }
+
+ [Fact]
+ public void FetcherReChecksMethodBase64AndBodyCapOnDecodedPayloads()
+ {
+ // Defense-in-depth on the Event Server: the method table, the base64 shape, and the
+ // request-body cap are re-checked against what the wire actually carried, never
+ // trusted from the requester (#55).
+ var fetcher = ReadFetcher();
+
+ Assert.Contains("ProxyForwardPolicy.IsForwardedMethod(method)", fetcher);
+ Assert.Contains("Convert.FromBase64String(payload.BodyBase64)", fetcher);
+ Assert.Contains("body.Length > ProxyForwardConnection.MaxRequestBodyBytes", fetcher);
+ }
}
}
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
index 6ab2685..f035f7a 100644
--- a/src/WebContent/ProxyFetchBackgroundPlugin.cs
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -168,6 +168,16 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch request body was not valid base64.");
return null;
}
+
+ // The client forwarder caps bodies before relaying; the fetcher re-caps what the
+ // wire actually carried, so no sender — old build or crafted payload — can make
+ // the VMS server replay an unbounded body at a target.
+ if (body.Length > ProxyForwardConnection.MaxRequestBodyBytes)
+ {
+ LogRefused(payload, $"the request body ({body.Length} bytes) is past the {ProxyForwardConnection.MaxRequestBodyBytes} byte request cap");
+ TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch request body exceeds the proxy's request cap.");
+ return null;
+ }
}
var query = new ProxyFetchQuery
diff --git a/src/WebContent/ProxyFetchTransport.cs b/src/WebContent/ProxyFetchTransport.cs
index 2073f3a..2b35b19 100644
--- a/src/WebContent/ProxyFetchTransport.cs
+++ b/src/WebContent/ProxyFetchTransport.cs
@@ -167,34 +167,43 @@ private static void ApplyRequestHeaders(HttpWebRequest request, List= 0 || header.Value.IndexOf('\n') >= 0 ||
+ header.Name.IndexOf(':') >= 0)
{
- case "USER-AGENT":
- request.UserAgent = header.Value;
- break;
- case "REFERER":
- request.Referer = header.Value;
- break;
- case "CONTENT-TYPE":
- request.ContentType = header.Value;
- break;
- default:
- if (TransportBlockedRequestHeaders.Contains(header.Name))
- {
+ continue;
+ }
+
+ try
+ {
+ switch (header.Name.ToUpperInvariant())
+ {
+ case "USER-AGENT":
+ request.UserAgent = header.Value;
break;
- }
+ case "REFERER":
+ request.Referer = header.Value;
+ break;
+ case "CONTENT-TYPE":
+ request.ContentType = header.Value;
+ break;
+ default:
+ if (TransportBlockedRequestHeaders.Contains(header.Name))
+ {
+ break;
+ }
- try
- {
request.Headers[header.Name] = header.Value;
- }
- catch (Exception)
- {
- // Restricted or malformed header names on the host stack: skipping the
- // single header beats failing the whole fetch.
- }
-
- break;
+ break;
+ }
+ }
+ catch (Exception)
+ {
+ // Restricted names or host-stack value validation (e.g. ContentType rejects
+ // non-printables on .NET Framework): skip the single header, never fail the
+ // fetch - FetchAsync's never-throws contract covers every wire-supplied value.
}
}
}
diff --git a/src/WebContent/ProxyForwardCodec.cs b/src/WebContent/ProxyForwardCodec.cs
index b4e2310..c0e148c 100644
--- a/src/WebContent/ProxyForwardCodec.cs
+++ b/src/WebContent/ProxyForwardCodec.cs
@@ -145,8 +145,9 @@ internal static bool TryParseRequestHead(string head, out ProxyForwardedRequest
};
///
- /// The request headers to carry on the wire to the Event Server fetcher: the browser's
- /// headers minus the outbound-blocked set, original casing and values intact.
+ /// The request headers to carry on the wire to the Event Server fetcher: the parsed
+ /// headers (first value per name) minus the outbound-blocked set, original casing and
+ /// values intact.
///
internal static List ToOutboundRequestHeaders(ProxyForwardedRequest request)
{
diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs
index d6f9a27..97a0302 100644
--- a/src/WebContent/ProxyForwardConnection.cs
+++ b/src/WebContent/ProxyForwardConnection.cs
@@ -283,7 +283,9 @@ private static bool ContainsHeadTerminator(byte[] bytes, int offset, int length)
private static int IndexOfHeadTerminator(byte[] bytes, int offset, int length)
{
- // CRLFCRLF, tolerating bare-LF clients, searched only against the newly appended range.
+ // CRLFCRLF, tolerating bare-LF clients. Rescanned from 0 each round: the leftover
+ // split for the request-body reader (#55) needs the terminator's absolute position,
+ // and a partially-arrived terminator in one read is only re-found once its tail lands.
for (var i = offset; i + 1 < length; i++)
{
if (bytes[i] == '\n' && bytes[i + 1] == '\n')
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
new file mode 100644
index 0000000..5374bce
--- /dev/null
+++ b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
@@ -0,0 +1,302 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace WebContent.UxTests.Harness;
+
+///
+/// Throwaway vendor-style login site for the #55 proxy login-flow probe (SC-2). A raw
+/// TcpListener rather than HttpListener: HttpListener needs a urlacl reservation to bind a
+/// LAN address unelevated, and the raw socket gives the probe exact control over multi-value
+/// Set-Cookie framing — the same control the proxy's own listener relies on.
+///
+/// It plays the camera-portal role the ticket names: a login form (POST), a session cookie
+/// issued with a 302, a dashboard that only answers when the cookie comes back, and an XHR
+/// endpoint the dashboard polls in the background. Every request is journaled so probe
+/// artifacts show what actually crossed the proxy.
+///
+public sealed class LoginTestServer : IDisposable
+{
+ public const string Username = "acmeadmin";
+ public const string Password = "camera1";
+
+ private readonly TcpListener _listener;
+ private readonly HashSet _issuedSessions = new();
+ private readonly List _journal = new();
+
+ /// The SID the last successful login issued (asserted against the browser's echo).
+ public string LastIssuedSessionId { get; private set; }
+
+ public int Port { get; }
+
+ public IReadOnlyList Journal { get { lock (_journal) return _journal.ToList(); } }
+
+ public LoginTestServer(int port)
+ {
+ Port = port;
+ _listener = new TcpListener(IPAddress.Any, port);
+ _listener.Start();
+ _ = Task.Run(AcceptLoopAsync);
+ }
+
+ public void Dispose() => _listener.Stop();
+
+ private async Task AcceptLoopAsync()
+ {
+ while (true)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync();
+ }
+ catch (Exception)
+ {
+ return;
+ }
+
+ _ = Task.Run(() => ServeAsync(client));
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client)
+ {
+ using (client)
+ {
+ client.NoDelay = true;
+ using var stream = client.GetStream();
+ try
+ {
+ await HandleAsync(stream);
+ }
+ catch (Exception exception)
+ {
+ lock (_journal)
+ {
+ _journal.Add($"request aborted: {exception.GetType().Name}: {exception.Message}");
+ }
+ }
+ }
+ }
+
+ private async Task HandleAsync(NetworkStream stream)
+ {
+ var head = await ReadHeadAsync(stream);
+ if (head == null)
+ {
+ return;
+ }
+
+ var lines = head.Replace("\n", "\r\n").Split(new[] { "\r\n" }, StringSplitOptions.None);
+ var requestLine = lines[0].Split(' ');
+ if (requestLine.Length != 3)
+ {
+ return;
+ }
+
+ var method = requestLine[0].ToUpperInvariant();
+ var path = requestLine[1];
+
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var line = 1; line < lines.Length && lines[line].Length > 0; line++)
+ {
+ var colon = lines[line].IndexOf(':');
+ if (colon > 0)
+ {
+ headers[lines[line].Substring(0, colon).Trim()] = lines[line].Substring(colon + 1).Trim();
+ }
+ }
+
+ string body = null;
+ if (headers.TryGetValue("Content-Length", out var lengthText) &&
+ int.TryParse(lengthText, out var length) && length > 0)
+ {
+ var buffer = new byte[length];
+ var readTotal = 0;
+ while (readTotal < length)
+ {
+ var read = await stream.ReadAsync(buffer, readTotal, length - readTotal);
+ if (read <= 0)
+ {
+ break;
+ }
+
+ readTotal += read;
+ }
+
+ body = Encoding.UTF8.GetString(buffer, 0, readTotal);
+ }
+
+ headers.TryGetValue("Cookie", out var cookieHeader);
+ lock (_journal)
+ {
+ _journal.Add($"{method} {path} body={body?.Length ?? 0}B cookie='{Truncate(cookieHeader, 120)}'");
+ }
+
+ switch (method, path)
+ {
+ case ("GET", "/"):
+ await WriteAsync(stream, Page(200, "ACME IPCStore - Login", LoginForm, null));
+ return;
+
+ case ("POST", "/login"):
+ var form = body ?? string.Empty;
+ if (form.Contains($"username={Username}") && form.Contains($"password={Password}"))
+ {
+ var session = Guid.NewGuid().ToString("N");
+ lock (_issuedSessions)
+ {
+ _issuedSessions.Add(session);
+ }
+
+ LastIssuedSessionId = session;
+ await WriteAsync(stream, Page(
+ 302,
+ "Found",
+ "redirecting...",
+ $"SID={session}; Path=/; HttpOnly",
+ "/dashboard"));
+ return;
+ }
+
+ await WriteAsync(stream, Page(401, "ACME IPCStore - Login failed",
+ "ACME IPCStore: bad credentials " + LoginForm, null));
+ return;
+
+ case ("GET", "/dashboard"):
+ if (!HasValidSession(cookieHeader))
+ {
+ await WriteAsync(stream, Page(403, "ACME IPCStore - Not signed in",
+ "NOT SIGNED IN
", null));
+ return;
+ }
+
+ await WriteAsync(stream, Page(200, "ACME Portal - Dashboard",
+ """
+ ACME Camera Portal
+ SIGNED IN
+ COOKIE-HEADER [[{0}]]
+ XHR-PENDING
+
+ """.Replace("{0}", HtmlEscape(cookieHeader)), null));
+ return;
+
+ case ("GET", "/api/status"):
+ if (!HasValidSession(cookieHeader))
+ {
+ await WriteAsync(stream, Page(403, "denied", "XHR-DENIED", null, contentType: "text/plain; charset=utf-8"));
+ return;
+ }
+
+ await WriteAsync(stream, Page(200, "ok", $"XHR-OK status=online cookie='{Truncate(cookieHeader, 60)}'", null, contentType: "text/plain; charset=utf-8"));
+ return;
+
+ default:
+ await WriteAsync(stream, Page(404, "not found", "not found", null, contentType: "text/plain; charset=utf-8"));
+ return;
+ }
+ }
+
+ private bool HasValidSession(string cookieHeader)
+ {
+ if (string.IsNullOrEmpty(cookieHeader))
+ {
+ return false;
+ }
+
+ foreach (var part in cookieHeader.Split(';'))
+ {
+ var pair = part.Trim();
+ if (!pair.StartsWith("SID=", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var value = pair.Substring(4);
+ lock (_issuedSessions)
+ {
+ if (_issuedSessions.Contains(value))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private const string LoginForm = """
+
+ """;
+
+ private static byte[] Page(
+ int status,
+ string title,
+ string bodyHtml,
+ string setCookie,
+ string redirectLocation = null,
+ string contentType = "text/html; charset=utf-8")
+ {
+ var body = Encoding.UTF8.GetBytes(
+ $"{title} {bodyHtml}");
+ var head = new StringBuilder();
+ head.Append($"HTTP/1.1 {status} {(status == 200 ? "OK" : status == 302 ? "Found" : "Status")}\r\n");
+ head.Append($"Content-Type: {contentType}\r\n");
+ if (setCookie != null)
+ {
+ head.Append($"Set-Cookie: {setCookie}\r\n");
+ }
+
+ if (redirectLocation != null)
+ {
+ head.Append($"Location: {redirectLocation}\r\n");
+ }
+
+ head.Append($"Content-Length: {body.Length}\r\n");
+ head.Append("Connection: close\r\n\r\n");
+ return Encoding.UTF8.GetBytes(head.ToString()).Concat(body).ToArray();
+ }
+
+ private static async Task ReadHeadAsync(NetworkStream stream)
+ {
+ var received = new MemoryStream();
+ var buffer = new byte[4096];
+ while (received.Length < 64 * 1024)
+ {
+ var read = await stream.ReadAsync(buffer, 0, buffer.Length);
+ if (read <= 0)
+ {
+ return null;
+ }
+
+ received.Write(buffer, 0, read);
+ var bytes = received.GetBuffer();
+ for (var i = 0; i + 3 < received.Length; i++)
+ {
+ if (bytes[i] == '\r' && bytes[i + 1] == '\n' && bytes[i + 2] == '\r' && bytes[i + 3] == '\n')
+ {
+ return Encoding.UTF8.GetString(bytes, 0, i);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static async Task WriteAsync(NetworkStream stream, byte[] response)
+ {
+ await stream.WriteAsync(response, 0, response.Length);
+ await stream.FlushAsync();
+ }
+
+ private static string Truncate(string value, int max) =>
+ value == null ? string.Empty : (value.Length <= max ? value : value.Substring(0, max) + "...");
+
+ private static string HtmlEscape(string value) =>
+ (value ?? string.Empty).Replace("&", "&").Replace("<", "<").Replace(">", ">");
+}
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
new file mode 100644
index 0000000..68d894a
--- /dev/null
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -0,0 +1,212 @@
+using FlaUI.Core;
+using FlaUI.Core.AutomationElements;
+using WebContent.UxTests.Harness;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace WebContent.UxTests;
+
+///
+/// Live acceptance probe for #55 (SC-2 in docs/verification-matrix.md): a vendor-style login
+/// flow through the proxied workspace browser. A POST login form, a 302 + Set-Cookie session
+/// round-trip, a cookie-gated dashboard, and a background XHR all cross the
+/// browser -> loopback listener -> MIP messaging -> Event Server fetcher chain with their
+/// method, headers, and body intact. The probe is self-setting: it ensures the login server's
+/// origin sits in the workspace proxy allowlist (Management Client phase), serves the login
+/// site from this machine via (the Event Server fetches it from
+/// the server network), then drives the login in Smart Client over CDP.
+/// Artifacts land in webcontent-sc-proxy-probe\<run> (sc-login-probe-*.png / .txt).
+///
+public class ScProxyLoginProbeTests
+{
+ ///
+ /// Origin the login site is served from and the workspace must allowlist. Override with
+ /// UX_LOGIN_TARGET when this machine's server-network address differs; the port must match
+ /// the allowlisted entry because the fetcher is driven at exactly this origin.
+ ///
+ private const string DefaultLoginTarget = "http://172.16.128.210:18123";
+
+ private readonly ITestOutputHelper _output;
+
+ public ScProxyLoginProbeTests(ITestOutputHelper output) => _output = output;
+
+ [Fact]
+ [Trait("Category", "probe")]
+ public async Task ProbeProxiedVendorLoginFlow()
+ {
+ var config = UxConfig.Load();
+ Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe"));
+ _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}");
+
+ var target = Environment.GetEnvironmentVariable("UX_LOGIN_TARGET") ?? DefaultLoginTarget;
+ var targetUri = new Uri(target);
+ var log = new System.Text.StringBuilder();
+ string failure = null;
+ string title = null;
+ string signedIn = null;
+ string xhrStatus = null;
+ string cookieEcho = null;
+ LoginTestServer server = null;
+
+ try
+ {
+ // Phase 1: the workspace proxy allowlist must carry the login origin before the
+ // Smart Client logs in — a target the fetcher refuses is a NotAllowed frame, not a
+ // login page.
+ EnsureAllowlistedTarget(config, target, log);
+
+ // Phase 2: serve the vendor site on THIS machine; the Event Server proves server-side
+ // reachability by fetching it from the VMS network while the browser never goes direct.
+ server = new LoginTestServer(targetUri.Port);
+ log.AppendLine($"login server bound to port {targetUri.Port}, origin {target}");
+
+ // Phase 3: drive the real login flow in the proxied Smart Client browser.
+ CdpConnector.CleanStalePortFiles(config.WebView2DataRoot);
+ ProcessKiller.KillClients("Client");
+ ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
+
+ using var sc = ClientSession.Launch(config.SmartClientExe);
+ var login = LoginFlow.WaitForLoginWindow(sc);
+ LoginFlow.Login(sc, login, config);
+ LoginFlow.AcceptCertificateTrust(sc);
+ var main = LoginFlow.WaitForSmartClientMain(sc);
+ SmartClientUi.Maximize(main);
+ Thread.Sleep(1500);
+ main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow");
+
+ main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName);
+ var item = SmartClientUi.WaitForMipViewItem(main);
+ Assert.NotNull(item);
+
+ var addressBar = item.FindFirstDescendant(cf => cf.ByAutomationId("addressBar"));
+ if (addressBar == null)
+ {
+ throw new InvalidOperationException("No addressBar in the proxied workspace view; cannot drive the login probe.");
+ }
+
+ // Filter-registration head start (documented 3-4 s), same as the SC-1 probe.
+ Thread.Sleep(6000);
+
+ SmartClientUi.NavigateAddress(addressBar, targetUri.AbsoluteUri);
+
+ await using var cdp = new CdpConnector();
+ var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot);
+ var page = await CdpConnector.WaitForPageAsync(browser, targetUri.Host);
+
+ var form = await page.WaitForSelectorAsync("#signin", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 });
+ if (form == null)
+ {
+ throw new InvalidOperationException($"login form not rendered at {targetUri} — page shows something else");
+ }
+
+ title = await page.TitleAsync();
+ log.AppendLine($"login page title='{title}'");
+
+ await page.FillAsync("#username", LoginTestServer.Username);
+ await page.FillAsync("#password", LoginTestServer.Password);
+ await page.ClickAsync("#signin");
+
+ // The POST -> 302+Set-Cookie -> GET /dashboard chain runs entirely through the proxy;
+ // only a session that round-tripped reaches the signed-in marker.
+ var marker = await page.WaitForSelectorAsync("#signed-in", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 60000 });
+ if (marker == null)
+ {
+ throw new InvalidOperationException("#signed-in never appeared — cookie/session round-trip failed");
+ }
+
+ signedIn = await marker.TextContentAsync();
+ title = await page.TitleAsync();
+ cookieEcho = await (await page.QuerySelectorAsync("#cookie-echo"))?.TextContentAsync();
+ xhrStatus = await (await page.QuerySelectorAsync("#xhr-status"))?.TextContentAsync();
+
+ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
+ {
+ Path = Artifacts.PathOf("sc-login-probe-dashboard.png"),
+ });
+
+ log.AppendLine($"after login: title='{title}' signedIn='{signedIn}' xhr='{xhrStatus}' cookieEcho='{cookieEcho}'");
+ lock (server)
+ {
+ foreach (var entry in server.Journal)
+ {
+ log.AppendLine($"server: {entry}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ failure = $"{ex.GetType().Name}: {ex.Message}";
+ log.AppendLine($"probe failed: {failure}");
+ }
+ finally
+ {
+ server?.Dispose();
+ Artifacts.WriteText("sc-login-probe-results.txt", log.ToString());
+ _output.WriteLine(log.ToString());
+ ProcessKiller.KillClients("Client");
+ ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
+ }
+
+ // The probe goes RED like SC-1: a login that loops back to the form, a proxy error page,
+ // or a background XHR that comes back denied all fail the run, not merely record it.
+ Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}");
+ Assert.NotNull(title);
+ Assert.False(title.StartsWith("Web Content proxy:"), $"browser showed the proxy error page instead of the vendor site: '{title}'");
+ Assert.Equal("SIGNED IN", signedIn);
+ Assert.NotNull(xhrStatus);
+ Assert.StartsWith("XHR-OK", xhrStatus);
+ Assert.Contains("POST /login", string.Join("\n", log));
+ }
+
+ ///
+ /// Management Client phase: make sure the login origin is in the McWorkspace proxy target
+ /// list (proxy enabled), saving only when the list actually changed. Runs before the Smart
+ /// Client so the fetched configuration carries the new allowlist.
+ ///
+ private void EnsureAllowlistedTarget(UxConfig config, string target, System.Text.StringBuilder log)
+ {
+ ProcessKiller.KillClients("VideoOS.Administration");
+ using var mc = ClientSession.Launch(config.AdminClientExe);
+ var mcLogin = LoginFlow.WaitForLoginWindow(mc);
+ LoginFlow.Login(mc, mcLogin, config);
+ LoginFlow.AcceptCertificateTrust(mc);
+ var mcMain = LoginFlow.WaitForAdminMain(mc);
+ ManagementClientUi.CloseStrayLoginDialogs(mc);
+
+ ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName);
+ ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName);
+
+ var targetsBox = ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 30);
+ var current = ReadValue(targetsBox);
+ log.AppendLine($"proxy targets before: '{current}'");
+
+ var lines = (current ?? string.Empty)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(line => line.Trim())
+ .Where(line => line.Length > 0)
+ .ToList();
+
+ if (lines.Any(line => string.Equals(line, target, StringComparison.OrdinalIgnoreCase)))
+ {
+ log.AppendLine($"target '{target}' already allowlisted; no Management Client edit needed");
+ return;
+ }
+
+ ManagementClientUi.EnsureCheckBox(mcMain, "chkProxyEnabled", true);
+ lines.Add(target);
+ ManagementClientUi.SetValue(mcMain, "txtProxyTargets", string.Join("\n", lines));
+
+ if (!ManagementClientUi.Save(mcMain, 20))
+ {
+ throw new InvalidOperationException($"Save never became enabled after adding '{target}' to the proxy targets.");
+ }
+
+ log.AppendLine($"allowlisted '{target}' and saved");
+ }
+
+ private static string ReadValue(AutomationElement element)
+ {
+ try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; }
+ catch { return element.Name; }
+ }
+}
From dd5dcad98748a37ac59e482cdfe556404dbc08f0 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 10:43:33 -0700
Subject: [PATCH 010/187] test(proxy): SC-2 probe harness - XHR endpoints serve
raw text/plain bodies, not HTML-wrapped, so #xhr-status asserts the exact
payload the fetcher carried
---
.../Harness/LoginTestServer.cs | 21 ++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
index 5374bce..244e7b6 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
@@ -185,15 +185,15 @@ await WriteAsync(stream, Page(200, "ACME Portal - Dashboard",
case ("GET", "/api/status"):
if (!HasValidSession(cookieHeader))
{
- await WriteAsync(stream, Page(403, "denied", "XHR-DENIED", null, contentType: "text/plain; charset=utf-8"));
+ await WriteAsync(stream, Plain(403, "XHR-DENIED"));
return;
}
- await WriteAsync(stream, Page(200, "ok", $"XHR-OK status=online cookie='{Truncate(cookieHeader, 60)}'", null, contentType: "text/plain; charset=utf-8"));
+ await WriteAsync(stream, Plain(200, $"XHR-OK status=online cookie='{Truncate(cookieHeader, 60)}'"));
return;
default:
- await WriteAsync(stream, Page(404, "not found", "not found", null, contentType: "text/plain; charset=utf-8"));
+ await WriteAsync(stream, Plain(404, "not found"));
return;
}
}
@@ -262,6 +262,21 @@ private static byte[] Page(
return Encoding.UTF8.GetBytes(head.ToString()).Concat(body).ToArray();
}
+ ///
+ /// Raw text/plain response (no HTML document shell): an XHR endpoint's body IS its payload,
+ /// so the dashboard can assert the exact text the fetcher carried back.
+ ///
+ private static byte[] Plain(int status, string text)
+ {
+ var body = Encoding.UTF8.GetBytes(text);
+ var head = new StringBuilder();
+ head.Append($"HTTP/1.1 {status} {(status == 200 ? "OK" : status == 403 ? "Forbidden" : "Not Found")}\r\n");
+ head.Append("Content-Type: text/plain; charset=utf-8\r\n");
+ head.Append($"Content-Length: {body.Length}\r\n");
+ head.Append("Connection: close\r\n\r\n");
+ return Encoding.UTF8.GetBytes(head.ToString()).Concat(body).ToArray();
+ }
+
private static async Task ReadHeadAsync(NetworkStream stream)
{
var received = new MemoryStream();
From 2d35b75b1367ba7b5f6152c34d5f8fb01d772404 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 10:50:38 -0700
Subject: [PATCH 011/187] test(proxy): SC-2 probe harness hardening - wait for
the dashboard XHR to resolve before reading #xhr-status (XHR-PENDING
placeholder race); Management Client tree search gets a grace period while
the tree populates after login
---
tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs | 7 +++++++
tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs | 8 ++++++++
2 files changed, 15 insertions(+)
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index 55d4fad..e5181f8 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -301,6 +301,13 @@ public static void EnsureExpanded(Window mainWindow, string name, params string[
Thread.Sleep(1500);
continue;
}
+ // The tree can still be populating right after login (server round-trips):
+ // give the search itself a grace period before declaring the node absent.
+ if (attempt < 3)
+ {
+ Thread.Sleep(2000);
+ continue;
+ }
throw new InvalidOperationException($"Tree node '{name}' not found (attempt {attempt}).");
}
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 68d894a..420af5f 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -117,6 +117,14 @@ public async Task ProbeProxiedVendorLoginFlow()
signedIn = await marker.TextContentAsync();
title = await page.TitleAsync();
cookieEcho = await (await page.QuerySelectorAsync("#cookie-echo"))?.TextContentAsync();
+
+ // The dashboard's background fetch resolves asynchronously — reading #xhr-status the
+ // instant the dashboard renders races it and catches the XHR-PENDING placeholder.
+ // Wait for the XHR to land (either verdict), THEN read it.
+ await page.WaitForFunctionAsync(
+ "() => { var el = document.getElementById('xhr-status'); return el && el.textContent && el.textContent !== 'XHR-PENDING'; }",
+ null,
+ new Microsoft.Playwright.PageWaitForFunctionOptions { Timeout = 30000 });
xhrStatus = await (await page.QuerySelectorAsync("#xhr-status"))?.TextContentAsync();
await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
From bda0cac4762d39967a3e84581ca63050d43f2d94 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:05:13 -0700
Subject: [PATCH 012/187] test(harness): Management Client tree navigation
without keyboard walking - EnsureExpanded enumerates realized rows,
double-clicks the target, and wheel-scrolls for below-the-fold rows/children
(geometry expansion check); AcceptCertificateTrust exits as soon as the
client main window is up instead of polling its full timeout; SC-2 probe
collapses to root (fast double-click route) then walks the plugin chain;
SelectNode sleeps tightened
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 31 +++-
.../Harness/ManagementClientUi.cs | 167 ++++++++++++------
.../ScProxyLoginProbeTests.cs | 6 +
3 files changed, 149 insertions(+), 55 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index 6af65a4..f2cc7a8 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -52,6 +52,7 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
"_btnInstall",
"_btnOK",
"_btnYes",
+ "_btnConnect", // the trust prompt's accept button is 'Connect' (paired with 'Close'/Cancel)
};
private static readonly string[] PreferredTrustButtonNames =
@@ -63,6 +64,7 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
"add",
"ok",
"yes",
+ "connect",
};
///
@@ -123,9 +125,36 @@ public static void AcceptCertificateTrust(ClientSession session, int timeoutSeco
return;
}
}
+ else if (HasMainSessionWindow(session))
+ {
+ // Login completed with NO dialog pending (the cert is already in the local store
+ // from an earlier session): polling to the full timeout here was 30 s of dead
+ // time between login and every tree navigation.
+ return;
+ }
+
+ Thread.Sleep(150);
+ }
+ }
- Thread.Sleep(250);
+ /// True when the client's own main window is up (Smart Client or Management Client).
+ private static bool HasMainSessionWindow(ClientSession session)
+ {
+ foreach (var w in session.Windows().ToList())
+ {
+ try
+ {
+ if (w.AutomationId == "_mainApplicationWindow") return true;
+ var name = w.Name ?? string.Empty;
+ var cls = w.ClassName ?? string.Empty;
+ if (name.Contains("Management Client", StringComparison.OrdinalIgnoreCase)
+ && cls.StartsWith("WindowsForms", StringComparison.Ordinal)
+ && w.BoundingRectangle.Width > 400)
+ return true;
+ }
+ catch { }
}
+ return false;
}
private static bool IsNegativeButton(string name) =>
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index e5181f8..9e0fc83 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -31,23 +31,6 @@ public static AutomationElement WaitForTreeItem(Window mainWindow, string name,
throw new TimeoutException($"Tree item '{name}' never appeared.");
}
- ///
- /// Finds a node under the currently selected tree node by walking DOWN with the keyboard.
- /// The WinForms tree only realizes visible rows, so a newly expanded node's children
- /// below the fold are invisible to UIA until the selection scrolls them into view.
- ///
- public static AutomationElement FindChildByWalkingDown(Window mainWindow, string name, int maxSteps = 40)
- {
- for (var step = 0; step < maxSteps; step++)
- {
- var item = FindTreeItem(mainWindow, name);
- if (item != null) return item;
- FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.DOWN);
- Thread.Sleep(50);
- }
- return FindTreeItem(mainWindow, name);
- }
-
/// Finds a tree node whose name contains the fragment (the Home root carries the version suffix).
public static AutomationElement WaitForTreeItemContaining(Window mainWindow, string fragment, int timeoutSeconds = 15)
{
@@ -153,20 +136,20 @@ public static void SelectNode(Window mainWindow, string name)
var item = FindTreeItem(mainWindow, name)
?? throw new InvalidOperationException($"Tree item '{name}' not found.");
try { if (item.Patterns.ScrollItem.IsSupported) item.Patterns.ScrollItem.Pattern.ScrollIntoView(); } catch { }
- Thread.Sleep(300);
+ Thread.Sleep(150);
try
{
if (item.Patterns.SelectionItem.IsSupported) item.Patterns.SelectionItem.Pattern.Select();
}
catch { }
- Thread.Sleep(400);
+ Thread.Sleep(200);
ClickLabel(mainWindow, item);
// MC's tree has LabelEdit active: clicking the label of an already-selected node arms
// the inline rename editor. ESC on the freshly focused tree cancels that arming; without
// this, the captured node renders in edit mode and rename commits can even raise
// "ItemName.set ...: Not valid to set dirty state at the line!!!" error dialogs.
FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.ESCAPE);
- Thread.Sleep(300);
+ Thread.Sleep(150);
}
/// Closes any leftover login/connection dialog that would steal keyboard focus from the tree.
@@ -277,65 +260,141 @@ public static bool ClickMenuItem(ClientSession session, string nameContains)
}
///
- /// Ensures the named tree node is expanded. MC tree items expose NO ExpandCollapse pattern,
- /// but LegacyIAccessible.DoDefaultAction() (and a real double-click on the label) toggle the
- /// node, and collapsing un-realizes the subtree in UIA — so expansion state is verified by
- /// the expected children being realized, toggling the node whenever they are missing.
+ /// Ensures the named tree node is expanded. Navigation is enumerate-and-click: the realized
+ /// rows are listed, the target's label is double-clicked, and the wheel scrolls when the node
+ /// or its children sit below the fold (MC tree rows are only realized while visible).
+ /// Expansion state is read from geometry — the MC tree exposes NO ExpandCollapse pattern, but
+ /// a node is expanded exactly when the next realized row is indented deeper, the same trick
+ /// CollapseTreeToRoot uses. The old keyboard walk (O(index) DOWN presses per attempt, re-walked
+ /// on every retry, plus fixed 800–1500 ms sleeps) was the source of the 10+ second stalls.
///
public static void EnsureExpanded(Window mainWindow, string name, params string[] expectedChildNames)
{
- for (var attempt = 0; attempt < 6; attempt++)
+ var deadline = DateTime.UtcNow.AddSeconds(45);
+ var strategy = 0;
+ var scrollPass = 0;
+ var ancestorsTouched = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ while (DateTime.UtcNow < deadline)
{
- if (ChildrenAppeared(mainWindow, expectedChildNames)) return;
- var items = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.TreeItem)).ToList();
+ if (expectedChildNames.Length > 0 && ChildrenAppeared(mainWindow, expectedChildNames)) return;
+
+ var items = RealizedTreeItems(mainWindow);
var node = items.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
+
if (node == null)
{
- // Possibly the whole tree (or Home) is collapsed: open Home once and retry.
- var home = items.FirstOrDefault(t => (t.Name ?? string.Empty).StartsWith("Home", StringComparison.OrdinalIgnoreCase));
- if (home != null && items.Count <= 2)
- {
- ClickLabel(mainWindow, home);
- Thread.Sleep(800);
- FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.RIGHT);
- Thread.Sleep(1500);
- continue;
- }
- // The tree can still be populating right after login (server round-trips):
- // give the search itself a grace period before declaring the node absent.
- if (attempt < 3)
+ // Target not realized: wheel-scroll the whole list looking for it, then open
+ // collapsed level-1 ancestors one at a time until the subtree materializes.
+ if (scrollPass < MaxTreeScrollPasses)
{
- Thread.Sleep(2000);
+ WheelTree(mainWindow, +4);
+ scrollPass++;
continue;
}
- throw new InvalidOperationException($"Tree node '{name}' not found (attempt {attempt}).");
+ WheelTree(mainWindow, -30);
+ scrollPass = 0;
+ if (!ExpandOneCollapsedLevel1(mainWindow, ancestorsTouched)) break;
+ continue;
}
- if (attempt < 3)
+ var expanded = IsExpandedIn(items, node);
+ if (expanded && expectedChildNames.Length == 0) return;
+
+ if (!expanded)
{
- // Keyboard route: walk DOWN to the node's visible index, press RIGHT. This is what
- // plugin-injected nodes honour (DoDefaultAction / double-click just open a page).
- var home = items.FirstOrDefault(t => (t.Name ?? string.Empty).StartsWith("Home", StringComparison.OrdinalIgnoreCase));
- if (home != null)
+ if (items.IndexOf(node) + 1 >= items.Count && scrollPass < MaxTreeScrollPasses)
{
- var idx = items.IndexOf(node);
- KeyboardWalkTo(mainWindow, home, idx);
- FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.RIGHT);
- Thread.Sleep(1200);
+ // Last realized row: its children may simply sit unrealized below the fold.
+ WheelTree(mainWindow, +4);
+ scrollPass++;
continue;
}
+ ToggleNode(node, strategy++ % 3);
+ Thread.Sleep(250);
+ continue;
+ }
+
+ // Expanded but the expected children never showed up in any scan: below the fold.
+ if (scrollPass < MaxTreeScrollPasses)
+ {
+ WheelTree(mainWindow, +4);
+ scrollPass++;
+ continue;
}
- ToggleNode(node, Math.Min(attempt, 2));
- Thread.Sleep(1200);
+ ToggleNode(node, strategy++ % 3);
+ Thread.Sleep(250);
}
- if (expectedChildNames.Length > 0)
+
+ if (expectedChildNames.Length > 0 && !ChildrenAppeared(mainWindow, expectedChildNames))
throw new InvalidOperationException($"Could not expand '{name}': none of [{string.Join(", ", expectedChildNames)}] appeared.");
+ if (expectedChildNames.Length == 0)
+ throw new InvalidOperationException($"Could not expand '{name}': geometry never showed a deeper-indented row under it.");
+ }
+
+ /// Tree rows the WinForms tree currently realizes, in visual (top-to-bottom) order.
+ private static List RealizedTreeItems(Window mainWindow)
+ {
+ var treeNav = mainWindow.FindFirstDescendant(cf => cf.ByAutomationId("TreeNavigation"));
+ var items = (treeNav ?? (AutomationElement)mainWindow)
+ .FindAllDescendants(cf => cf.ByControlType(ControlType.TreeItem))
+ .Where(t => { try { return t.BoundingRectangle.Height > 0; } catch { return false; } })
+ .ToList();
+ items.Sort((x, y) => x.BoundingRectangle.Y.CompareTo(y.BoundingRectangle.Y));
+ return items;
+ }
+
+ /// True exactly when the next realized row is indented deeper than the node.
+ private static bool IsExpandedIn(List items, AutomationElement node)
+ {
+ var idx = items.IndexOf(node);
+ if (idx < 0 || idx + 1 >= items.Count) return false;
+ var a = items[idx].BoundingRectangle;
+ var b = items[idx + 1].BoundingRectangle;
+ if (a.Width <= 0 || b.Width <= 0) return false;
+ return b.X > a.X + 5;
+ }
+
+ private const int MaxTreeScrollPasses = 12;
+
+ ///
+ /// Double-clicks the first realized, collapsed level-1 row (Home and already-tried rows are
+ /// skipped) so the subtree that should contain the target becomes realized. One ancestor per
+ /// call keeps the search verifiable; the touched-set stops the next round re-collapsing it.
+ ///
+ private static bool ExpandOneCollapsedLevel1(Window mainWindow, HashSet touched)
+ {
+ var items = RealizedTreeItems(mainWindow);
+ var home = items.FirstOrDefault(t => (t.Name ?? string.Empty).StartsWith("Home", StringComparison.OrdinalIgnoreCase));
+ if (home == null) return false;
+ if (items.Count == 1)
+ {
+ ToggleNode(home, 0); // MC persists tree state: the whole tree can start collapsed at Home.
+ Thread.Sleep(300);
+ return true;
+ }
+ var homeIdx = items.IndexOf(home);
+ if (homeIdx + 1 >= items.Count) return false;
+ var level1X = items[homeIdx + 1].BoundingRectangle.X;
+ for (var i = homeIdx + 1; i < items.Count; i++)
+ {
+ var node = items[i];
+ if (Math.Abs(node.BoundingRectangle.X - level1X) > 1) continue;
+ if (i + 1 < items.Count && items[i + 1].BoundingRectangle.X > node.BoundingRectangle.X + 5) continue;
+ if (!touched.Add(node.Name ?? string.Empty)) continue;
+ ToggleNode(node, 1);
+ Thread.Sleep(300);
+ return true;
+ }
+ return false;
}
///
/// Toggles expand/collapse of a node. Strategy 0 uses LegacyIAccessible.DoDefaultAction
/// (works for most nodes), strategy 1 does a real double-click on the label (proven for nodes
/// where DoDefaultAction is a silent no-op), strategy 2 selects the node first, then double-clicks.
+ /// EnsureExpanded verifies the toggle by geometry and escalates strategies, so no keyboard
+ /// fallback is needed here.
///
private static void ToggleNode(AutomationElement node, int strategy = 0)
{
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 420af5f..7fd6231 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -180,7 +180,13 @@ private void EnsureAllowlistedTarget(UxConfig config, string target, System.Text
LoginFlow.AcceptCertificateTrust(mc);
var mcMain = LoginFlow.WaitForAdminMain(mc);
ManagementClientUi.CloseStrayLoginDialogs(mc);
+ ManagementClientUi.WaitForTreeReady(mcMain);
+ // Canonical starting state (fast double-click collapse, no keyboard), then walk the
+ // known chain top-down (enumerate rows + click + wheel scroll): each step realizes the
+ // rows the next one needs, so no blind walking or long sleeps are involved.
+ ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName });
+ ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName);
ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName);
ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName);
From 46dbd7f131b111a536c85162cfbafd3d3b537408 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:07:47 -0700
Subject: [PATCH 013/187] docs: SC-2 live-host record - vendor login
POST+cookie+XHR round-trip through the proxy passes on drop 6C373E4D (both
machines, 2026-10-31)
---
docs/verification-matrix.md | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index df47c4d..83ef1da 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -210,10 +210,20 @@ simultaneous.
Server side (`UseCookies`/`AllowAutoRedirect` off — pinned by `ProxyFetchTransportTests`).
A login form that submits with a chunked body (modern `fetch()` + `ReadableStream` style —
ordinary forms and XHR always carry Content-Length) answers 411 "Length Required" by design.
-4. Automatable harness: extend `ScProxyFetchProbeTests` (probe category) — artifacts under
+4. Automatable harness: `ScProxyLoginProbeTests` (probe category) — it allowlists its own
+ login-server origin through Management Client, drives the login via CDP, and writes
+ `sc-login-probe-results.txt` (+ dashboard screenshot) under
`%TEMP%\webcontent-sc-proxy-probe\`; the probe goes red when the dashboard is not
reached or the cookie round-trip fails.
+ Record (2026-10-31, drop `6C373E4D…FFE1` on both machines, target `http://172.16.128.210:18123`
+ served by the probe's vendor-style login server): pass — login form POST relayed through
+ messaging with its body (`server: POST /login body=35B`), the 302 + `Set-Cookie` round-tripped
+ (browser shows `title='ACME Portal - Dashboard'`, `SIGNED IN`, `cookieEcho='COOKIE-HEADER
+ [[SID=…]]'` — never the form again), and the page's background `fetch()` XHR reached the target
+ with the session cookie attached (`server: GET /api/status cookie='SID=…'`, rendered as
+ `XHR-OK status=online cookie='SID=…'`). Full probe run ≈1 min.
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
From 65cb045c493e8fb00a8e9048ea8142b685248ee9 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:33:19 -0700
Subject: [PATCH 014/187] feat(proxy): #56 fetcher TLS decision - full chain
validation by default, dedicated TlsValidation refusal with actionable
message, per-request (never ServicePointManager) override hook; ADR-0002
records the loopback-termination design
---
...nate-proxied-https-at-loopback-listener.md | 71 +++++
.../ProxyFetchTlsDecisionTests.cs | 283 ++++++++++++++++++
src/WebContent/ProxyFetchContract.cs | 21 ++
src/WebContent/ProxyFetchTransport.cs | 100 ++++++-
4 files changed, 470 insertions(+), 5 deletions(-)
create mode 100644 docs/adr/0002-terminate-proxied-https-at-loopback-listener.md
create mode 100644 src/WebContent.Tests/ProxyFetchTlsDecisionTests.cs
diff --git a/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md b/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md
new file mode 100644
index 0000000..d246914
--- /dev/null
+++ b/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md
@@ -0,0 +1,71 @@
+# 0002 — Terminate proxied HTTPS at the loopback listener; validate target TLS on the Event Server
+
+Date: 2026-10-31
+Status: Accepted
+
+## Context
+
+The workspace proxy (#52–#55) pins each proxied WebView2 browser to a loopback forwarding
+listener (`--proxy-server` for every scheme) and replays allowlisted requests from the Event
+Server network, because the Smart Client machine cannot reach the camera/VMS-network targets
+directly. Ticket #56 adds HTTPS targets with an admin "accept untrusted certificate" override
+stored per allowlisted target.
+
+Chromium always sends `CONNECT host:port` for `https://` URLs routed through an HTTP proxy —
+there is no absolute-form HTTPS proxy request. A blind CONNECT tunnel is useless here: the
+client machine has no network route to the target, so the tunnel cannot be opened from the
+client, and if TLS were tunneled to the browser the Event Server would never see the encrypted
+traffic — a per-target validation override on the Event Server fetcher would be meaningless
+(the browser would show its own certificate error that plugin code cannot bless).
+
+The alternatives considered:
+
+- **CONNECT tunnel + browser-side trust** (`--ignore-certificate-errors` variants): the
+ override would have to reach Chromium, no Event Server-side chain validation happens, and
+ the acceptance criteria for a validated fetch with a scoped override cannot be met.
+- **Never tunnel; forward `https://` in absolute form**: not producible by Chromium through an
+ HTTP proxy; page-level URL rewriting would be fragile against in-page links, scripts, and
+ redirects.
+- **Event-Server-side trust only**: impossible without terminating TLS somewhere in the plugin
+ chain first.
+
+## Decision
+
+Terminate TLS at the loopback listener and keep the security-critical validation hop on the
+Event Server:
+
+1. An allowlisted `CONNECT` is answered with `200 Connection established` and then terminated
+ by the listener with an ephemeral certificate for the target host, issued from a
+ per-session CA that is installed into the CurrentUser Trusted Root store for the life of
+ the proxied browser and removed afterwards. The browser therefore sees a genuine
+ `https://target` origin (no mixed-content or cert errors).
+2. The decrypted inner requests (origin-form + `Host`, resolved against the CONNECT origin)
+ ride the existing `ProxyFetchQuery` → MIP messaging → Event Server fetch pipeline
+ unchanged — the wire contract does not change.
+3. The Event Server fetcher performs the real TLS leg to the target with **full chain
+ validation by default** (no callback set), and applies the per-target override only via a
+ per-request `HttpWebRequest.ServerCertificateValidationCallback` — never
+ `ServicePointManager` globally — when the fetched origin carries the stored
+ `acceptUntrusted` flag on its allowlist entry.
+4. A non-allowlisted `CONNECT` stays refused (403), and validation failure without the flag
+ produces a dedicated `TlsValidation` fetch error with an actionable message.
+
+## Consequences
+
+- Two TLS legs exist for an https target: browser ↔ loopback (local IPC, ephemeral cert,
+ trust scoped to one session's certificate store entries) and Event Server ↔ target (the
+ real, validated leg). "Accept untrusted certificate" therefore scopes exactly to the
+ target origin and never to the whole system or to the browser's cert checking.
+- The per-session CA/leaf plumbing (`SslStream` server auth, `CertificateRequest`) is new
+ plugin surface; leaves are cached per host and both store writes and removal are logged.
+ Store access is CurrentUser (no elevation).
+- WebSocket (`wss://`) rides CONNECT too: the TLS-terminated inner request carrying an
+ `Upgrade:` header is refused with a clear 405 — relaying WebSocket through the fetch
+ pipeline stays out of scope.
+- Config format: `proxyTargets` entries gain an `acceptUntrusted` member; MC admin page rows
+ gain an `accept-untrusted` suffix token. Version skew degrades fail-closed (an old Event
+ Server ignores the unknown member and validates normally; a new Event Server reading old
+ JSON sees the flag absent = off).
+- The browser must trust the ephemeral CA. WebView2 honors the CurrentUser Trusted Root
+ store, so no Chromium flags are needed; the CA install/remove lifetime is tied to the
+ proxied browser's listener.
diff --git a/src/WebContent.Tests/ProxyFetchTlsDecisionTests.cs b/src/WebContent.Tests/ProxyFetchTlsDecisionTests.cs
new file mode 100644
index 0000000..0501711
--- /dev/null
+++ b/src/WebContent.Tests/ProxyFetchTlsDecisionTests.cs
@@ -0,0 +1,283 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Net.Security;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #56: the Event Server fetcher performs full TLS chain validation against an https
+ /// target by default and refuses an untrusted certificate with an actionable error; only the
+ /// per-target allowlist override (never a global or browser-side bypass) makes it accept one.
+ /// These pins run the real transport against a local self-signed TLS listener.
+ ///
+ public class ProxyFetchTlsDecisionTests : IDisposable
+ {
+ private readonly TestTlsServer _server = new TestTlsServer();
+
+ [Fact]
+ public async Task AnUntrustedTlsTargetIsRefusedWithTlsValidationAndAnActionableMessage()
+ {
+ var result = await new ProxyHttpFetcher().FetchAsync(
+ Get(_server.UrlFor("/index.html")), ProxyFetchLimits.Default());
+
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.TlsValidation, result.ErrorCode);
+ Assert.Contains("certificate", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("accept untrusted", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ThePerTargetOverrideAcceptsTheUntrustedCertificateForThatFetchOnly()
+ {
+ var result = await new ProxyHttpFetcher().FetchAsync(
+ Get(_server.UrlFor("/index.html")),
+ ProxyFetchLimits.Default(),
+ ProxyFetchTlsMode.AcceptUntrusted);
+
+ Assert.True(result.Success);
+ Assert.Equal(200, result.StatusCode);
+ Assert.Equal(Encoding.UTF8.GetBytes("tls probe page"), result.Body);
+ }
+
+ [Fact]
+ public async Task TheDefaultModeStillFetchesAnOrdinaryHttpTargetUnchanged()
+ {
+ // Guard that the TLS decision never disturbs the plain-http path that every #53-#55
+ // pin runs through: an http target must fetch exactly as before.
+ var server = new PlainHttpProbe();
+ try
+ {
+ var result = await new ProxyHttpFetcher().FetchAsync(
+ Get(server.UrlFor("/plain")), ProxyFetchLimits.Default());
+
+ Assert.True(result.Success);
+ Assert.Equal(Encoding.UTF8.GetBytes("plain"), result.Body);
+ }
+ finally
+ {
+ server.Dispose();
+ }
+ }
+
+ private static ProxyFetchQuery Get(string url)
+ {
+ return new ProxyFetchQuery { Uri = new Uri(url), Method = "GET" };
+ }
+
+ public void Dispose()
+ {
+ _server.Dispose();
+ }
+
+ private static int FreePort()
+ {
+ var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ return port;
+ }
+
+ ///
+ /// Loopback TLS listener with a self-signed (untrusted-chain) certificate — stands in for
+ /// the device web server of the ticket. Serves one fixed page per request head.
+ ///
+ private sealed class TestTlsServer : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly X509Certificate2 _certificate;
+ private readonly int _port;
+
+ internal TestTlsServer()
+ {
+ _certificate = CreateSelfSignedServerCertificate();
+ _port = FreePort();
+ _listener = new TcpListener(IPAddress.Loopback, _port);
+ _listener.Start();
+
+ Task.Run(AcceptLoopAsync);
+ }
+
+ internal string UrlFor(string path) => $"https://127.0.0.1:{_port}{path}";
+
+ private async Task AcceptLoopAsync()
+ {
+ while (true)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync();
+ }
+ catch (Exception)
+ {
+ return;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ using (client)
+ using (var stream = client.GetStream())
+ using (var tls = new SslStream(stream, false))
+ {
+ await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = _certificate,
+ EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12,
+ });
+
+ // Drain the request head, then answer with the fixed probe page.
+ var reader = new byte[8];
+ var head = new StringBuilder();
+ while (true)
+ {
+ var read = await tls.ReadAsync(reader, 0, reader.Length);
+ if (read <= 0)
+ {
+ return;
+ }
+
+ head.Append(Encoding.ASCII.GetString(reader, 0, read));
+ if (head.ToString().Contains("\r\n\r\n"))
+ {
+ break;
+ }
+ }
+
+ var body = Encoding.UTF8.GetBytes("tls probe page");
+ var response =
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ $"Content-Length: {body.Length}\r\n" +
+ "\r\n";
+ await tls.WriteAsync(Encoding.ASCII.GetBytes(response), 0, response.Length);
+ await tls.WriteAsync(body, 0, body.Length);
+ await tls.FlushAsync();
+ }
+ }
+ catch (Exception)
+ {
+ // A fetcher that refuses the certificate hangs up mid-handshake: expected.
+ }
+ });
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _listener.Stop();
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private static X509Certificate2 CreateSelfSignedServerCertificate()
+ {
+ using (var rsa = RSA.Create(2048))
+ {
+ var request = new CertificateRequest(
+ "CN=webcontent-proxy-tls-probe", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
+ var san = new SubjectAlternativeNameBuilder();
+ san.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(san.Build());
+
+ var notBefore = DateTime.UtcNow.AddDays(-1);
+ var notAfter = DateTime.UtcNow.AddDays(365);
+ var selfSigned = request.CreateSelfSigned(notBefore, notAfter);
+
+ // Schannel refuses ephemeral keys for server auth: round-trip through PFX so the
+ // private key lands in a persisted, exportable form SslStream can present.
+ using (selfSigned)
+ {
+ return new X509Certificate2(selfSigned.Export(X509ContentType.Pfx));
+ }
+ }
+ }
+
+ /// Smallest plain-http stand-in reusing the raw-socket style of the TLS probe.
+ private sealed class PlainHttpProbe : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly int _port;
+
+ internal PlainHttpProbe()
+ {
+ _port = FreePort();
+ _listener = new TcpListener(IPAddress.Loopback, _port);
+ _listener.Start();
+ Task.Run(async () =>
+ {
+ while (true)
+ {
+ try
+ {
+ using (var client = await _listener.AcceptTcpClientAsync())
+ using (var stream = client.GetStream())
+ {
+ var buffer = new byte[8];
+ var head = new StringBuilder();
+ while (true)
+ {
+ var read = await stream.ReadAsync(buffer, 0, buffer.Length);
+ if (read <= 0)
+ {
+ break;
+ }
+
+ head.Append(Encoding.ASCII.GetString(buffer, 0, read));
+ if (head.ToString().Contains("\r\n\r\n"))
+ {
+ break;
+ }
+ }
+
+ var body = Encoding.UTF8.GetBytes("plain");
+ var response =
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ $"Content-Length: {body.Length}\r\n" +
+ "\r\n";
+ await stream.WriteAsync(Encoding.ASCII.GetBytes(response), 0, response.Length);
+ await stream.WriteAsync(body, 0, body.Length);
+ await stream.FlushAsync();
+ }
+ }
+ catch (Exception)
+ {
+ return;
+ }
+ }
+ });
+ }
+
+ internal string UrlFor(string path) => $"http://127.0.0.1:{_port}{path}";
+
+ public void Dispose()
+ {
+ try
+ {
+ _listener.Stop();
+ }
+ catch
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index aae3456..511c4f1 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -62,6 +62,27 @@ internal static class ProxyFetchErrorCodes
/// any proxied workspace, so the Event Server refuses to reach it.
///
internal const string NotAllowed = "NotAllowed";
+
+ ///
+ /// Ticket #56: the https target's certificate failed chain validation and the target has
+ /// no per-target "accept untrusted certificate" override, so the fetch is refused.
+ ///
+ internal const string TlsValidation = "TlsValidation";
+ }
+
+ ///
+ /// How the Event Server fetcher treats TLS certificate validation on the https leg to the
+ /// target (#56). The caller decides per request from the target's allowlist entry —
+ /// AcceptUntrusted for exactly the origin whose entry carries the admin override,
+ /// ValidateNormally for everything else. Never a process-wide or browser-wide bypass.
+ ///
+ internal enum ProxyFetchTlsMode
+ {
+ /// Full chain validation; an untrusted certificate fails with TlsValidation.
+ ValidateNormally,
+
+ /// Validation errors accepted for this one already-allowlisted target.
+ AcceptUntrusted,
}
///
diff --git a/src/WebContent/ProxyFetchTransport.cs b/src/WebContent/ProxyFetchTransport.cs
index 2b35b19..98f781c 100644
--- a/src/WebContent/ProxyFetchTransport.cs
+++ b/src/WebContent/ProxyFetchTransport.cs
@@ -2,7 +2,9 @@
using System.Collections.Generic;
using System.IO;
using System.Net;
+using System.Net.Security;
using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
namespace WebContent
@@ -23,11 +25,22 @@ namespace WebContent
internal sealed class ProxyHttpFetcher
{
///
- /// Replay the request carried by the query under the given limits. Never throws: transport
- /// outcomes are reported in
+ /// Replay the request carried by the query under the given limits, validating the target's
+ /// TLS certificate normally (#56 default). Never throws: transport outcomes are reported in
/// the returned ProxyFetchResult so framing/logging can react to each failure class.
///
- internal async Task FetchAsync(ProxyFetchQuery query, ProxyFetchLimits limits)
+ internal Task FetchAsync(ProxyFetchQuery query, ProxyFetchLimits limits)
+ {
+ return FetchAsync(query, limits, ProxyFetchTlsMode.ValidateNormally);
+ }
+
+ ///
+ /// Replay the request with the given TLS decision for the https leg to the target (#56).
+ /// AcceptUntrusted comes from the target's own allowlist entry — the caller scopes it per
+ /// origin — and is applied ONLY to this request's own callback, never to the process-wide
+ /// ServicePointManager and never to any other target.
+ ///
+ internal async Task FetchAsync(ProxyFetchQuery query, ProxyFetchLimits limits, ProxyFetchTlsMode tlsMode)
{
var target = query == null ? null : query.Uri;
if (!ProxyPolicy.IsHttpUri(target))
@@ -39,6 +52,16 @@ internal async Task FetchAsync(ProxyFetchQuery query, ProxyFet
request.Method = string.IsNullOrWhiteSpace(query.Method) ? "GET" : query.Method.Trim().ToUpperInvariant();
request.UserAgent = "CascadiaWebContentPlugin/MIP-proxy-fetcher";
+ // #56: certificate validation on the target leg. Default is the runtime's full chain
+ // validation; a callback that always returns false when it rejects records WHY the
+ // fetch died (the exception class differs across runtimes, the callback firing does
+ // not), so the refusal can name the certificate instead of a vague transport error.
+ var tlsDecision = new TlsDecision(tlsMode);
+ if (target.Scheme == Uri.UriSchemeHttps)
+ {
+ request.ServerCertificateValidationCallback = tlsDecision.Evaluate;
+ }
+
// #55 passthrough rules: the browser owns the session. A 3xx and its Set-Cookie values
// relay back to the browser instead of being followed or stored here, so no target
// credential ever persists on the VMS server. UseCookies only exists on the .NET
@@ -85,7 +108,7 @@ internal async Task FetchAsync(ProxyFetchQuery query, ProxyFet
if (query.Body != null && query.Body.Length > 0)
{
- var writeFailure = await WriteRequestBodyAsync(request, query.Body, target, limits);
+ var writeFailure = await WriteRequestBodyAsync(request, query.Body, target, limits, tlsDecision);
if (writeFailure != null)
{
return writeFailure;
@@ -119,16 +142,31 @@ internal async Task FetchAsync(ProxyFetchQuery query, ProxyFet
}
catch (WebException webException)
{
+ if (tlsDecision.Rejected)
+ {
+ return TlsValidationFailure(target);
+ }
+
return MapWebException(webException, target, limits);
}
catch (IOException ioException)
{
+ if (tlsDecision.Rejected)
+ {
+ return TlsValidationFailure(target);
+ }
+
// The compat stack surfaces a refused/reset connection as IOException where the
// .NET Framework host reports WebException(ConnectFailure).
return Failure(ProxyFetchErrorCodes.Unreachable, $"{ProxyPolicy.CanonicalOrigin(target)}: {ioException.Message}");
}
catch (Exception exception)
{
+ if (tlsDecision.Rejected)
+ {
+ return TlsValidationFailure(target);
+ }
+
try
{
request.Abort();
@@ -230,7 +268,7 @@ private static void ApplyRequestHeaders(HttpWebRequest request, List WriteRequestBodyAsync(HttpWebRequest request, byte[] body, Uri target, ProxyFetchLimits limits)
+ private static async Task WriteRequestBodyAsync(HttpWebRequest request, byte[] body, Uri target, ProxyFetchLimits limits, TlsDecision tlsDecision)
{
request.ContentLength = body.Length;
try
@@ -259,10 +297,20 @@ private static async Task WriteRequestBodyAsync(HttpWebRequest
}
catch (WebException webException)
{
+ if (tlsDecision.Rejected)
+ {
+ return TlsValidationFailure(target);
+ }
+
return MapWebException(webException, target, limits);
}
catch (Exception exception)
{
+ if (tlsDecision.Rejected)
+ {
+ return TlsValidationFailure(target);
+ }
+
return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: request body not sent: {exception.Message}");
}
}
@@ -428,5 +476,47 @@ private static ProxyFetchResult Failure(string errorCode, string message)
ErrorMessage = message,
};
}
+
+ ///
+ /// #56: the per-request certificate-validation decision. The callback is attached to THIS
+ /// HttpWebRequest only — ServicePointManager's process-wide callback is never touched, so a
+ /// plugin running inside the Event Server cannot weaken TLS for any other caller, and the
+ /// override stays scoped to the one allowlisted origin the caller checked. A rejecting
+ /// callback records that fact so the resulting transport exception maps to TlsValidation
+ /// no matter which exception shape the host runtime produces.
+ ///
+ private sealed class TlsDecision
+ {
+ private readonly ProxyFetchTlsMode _mode;
+ internal bool Rejected;
+
+ internal TlsDecision(ProxyFetchTlsMode mode)
+ {
+ _mode = mode;
+ }
+
+ internal bool Evaluate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
+ {
+ if (_mode == ProxyFetchTlsMode.AcceptUntrusted)
+ {
+ return true;
+ }
+
+ if (sslPolicyErrors != SslPolicyErrors.None)
+ {
+ Rejected = true;
+ }
+
+ return sslPolicyErrors == SslPolicyErrors.None;
+ }
+ }
+
+ private static ProxyFetchResult TlsValidationFailure(Uri target)
+ {
+ var origin = ProxyPolicy.CanonicalOrigin(target);
+ return Failure(
+ ProxyFetchErrorCodes.TlsValidation,
+ $"{origin}: the TLS certificate failed validation (untrusted chain, expired, or hostname mismatch). The proxy validates target certificates and refuses this one; enable the per-target 'accept untrusted certificate' override for '{origin}' on the workspace page to relay this device.");
+ }
}
}
From 220c21c3e01e890298b2a5aecbbab95ad9996cb9 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:36:05 -0700
Subject: [PATCH 015/187] feat(proxy): #56 allowlist carries the per-target
accept-untrusted-certificate override - union snapshot splits flagged
origins, Event Server scopes the certificate mode to the exact allowlisted
origin, config member acceptUntrusted defaults absent=false
---
.../ProxyServerAllowlistTests.cs | 60 +++++++++++++++++++
src/WebContent/ProxyFetchBackgroundPlugin.cs | 13 +++-
src/WebContent/ProxyServerAllowlist.cs | 52 +++++++++++++---
src/WebContent/WorkspaceConfiguration.cs | 8 +++
4 files changed, 122 insertions(+), 11 deletions(-)
diff --git a/src/WebContent.Tests/ProxyServerAllowlistTests.cs b/src/WebContent.Tests/ProxyServerAllowlistTests.cs
index 83fca9a..7fc18a4 100644
--- a/src/WebContent.Tests/ProxyServerAllowlistTests.cs
+++ b/src/WebContent.Tests/ProxyServerAllowlistTests.cs
@@ -29,6 +29,9 @@ private static Dictionary WorkspaceProperties(bool enabled, para
private static string Target(string scheme, string host, int port) =>
$"{{\"scheme\":\"{scheme}\",\"host\":\"{host}\",\"port\":{port}}}";
+ private static string Target(string scheme, string host, int port, bool acceptUntrusted) =>
+ $"{{\"scheme\":\"{scheme}\",\"host\":\"{host}\",\"port\":{port},\"acceptUntrusted\":{(acceptUntrusted ? "true" : "false")}}}";
+
[Fact]
public void ForwardsOriginsAllowlistedByAnyProxiedWorkspace()
{
@@ -82,6 +85,63 @@ public void FailsClosedWhenTheConfigurationHasNeverResolved()
Assert.Contains("Configuration", reason);
}
+ [Fact]
+ public void UntrustedCertificateOverrideIsFalseForEveryTargetWithoutTheFlag()
+ {
+ var allowlist = new ProxyServerAllowlist(
+ () => new List>
+ {
+ WorkspaceProperties(true,
+ Target("https", "cam1.test", 8443),
+ Target("https", "cam2.test", 8443, false)),
+ },
+ Sink);
+
+ Assert.False(allowlist.AcceptsUntrustedCertificate(new Uri("https://cam1.test:8443/live")));
+ Assert.False(allowlist.AcceptsUntrustedCertificate(new Uri("https://cam2.test:8443/live")));
+ }
+
+ [Fact]
+ public void UntrustedCertificateOverrideIsTrueOnlyForTheOriginWhoseEntryCarriesIt()
+ {
+ // The AC3 shape: one flagged entry, one unflagged sibling on the SAME host pair —
+ // the override must never leak from a target to its neighbours.
+ var allowlist = new ProxyServerAllowlist(
+ () => new List>
+ {
+ WorkspaceProperties(true,
+ Target("https", "cam1.test", 8443, true),
+ Target("https", "cam1.test", 9443, false)),
+ WorkspaceProperties(true, Target("https", "portal.test", 443)),
+ },
+ Sink);
+
+ Assert.True(allowlist.AcceptsUntrustedCertificate(new Uri("https://cam1.test:8443/live/index.html")));
+ Assert.False(allowlist.AcceptsUntrustedCertificate(new Uri("https://cam1.test:9443/live/index.html")));
+ Assert.False(allowlist.AcceptsUntrustedCertificate(new Uri("https://portal.test/dashboard")));
+ }
+
+ [Fact]
+ public void UntrustedCertificateOverrideFollowsTheAllowlistGate()
+ {
+ // Proxy off: the entry counts for neither the allowlist nor the override. Config
+ // never resolved: the gate is closed and the override set stays empty (fail closed).
+ var allowlist = new ProxyServerAllowlist(
+ () => new List>
+ {
+ WorkspaceProperties(false, Target("https", "cam1.test", 8443, true)),
+ },
+ Sink);
+
+ Assert.False(allowlist.AcceptsUntrustedCertificate(new Uri("https://cam1.test:8443/")));
+
+ var closed = new ProxyServerAllowlist(
+ () => throw new InvalidOperationException("Configuration service is not available."),
+ Sink);
+
+ Assert.False(closed.AcceptsUntrustedCertificate(new Uri("https://cam1.test:8443/")));
+ }
+
[Fact]
public void KeepsServingTheLastKnownAllowlistWhenTheConfigServiceFlaps()
{
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
index f035f7a..48c3d4e 100644
--- a/src/WebContent/ProxyFetchBackgroundPlugin.cs
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -204,6 +204,13 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
return null;
}
+ // #56: the certificate mode comes from this target's own allowlist entry — the union
+ // snapshot resolved it to exactly this canonical origin. Every other target, proxied
+ // or not, keeps full TLS chain validation.
+ var tlsMode = target.Scheme == Uri.UriSchemeHttps && _allowlist.AcceptsUntrustedCertificate(target)
+ ? ProxyFetchTlsMode.AcceptUntrusted
+ : ProxyFetchTlsMode.ValidateNormally;
+
var slots = _fetchSlots;
if (slots == null)
{
@@ -221,7 +228,7 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
try
{
- await ServeFetchAsync(communication, payload, query, requester);
+ await ServeFetchAsync(communication, payload, query, requester, tlsMode);
}
finally
{
@@ -232,13 +239,13 @@ private object OnProxyFetchRequest(Message message, FQID destination, FQID sourc
return null;
}
- private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetchRequestPayload payload, ProxyFetchQuery query, FQID requesterFqid)
+ private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetchRequestPayload payload, ProxyFetchQuery query, FQID requesterFqid, ProxyFetchTlsMode tlsMode)
{
var target = query.Uri;
try
{
var limits = ProxyFetchLimits.Default();
- var result = await new ProxyHttpFetcher().FetchAsync(query, limits);
+ var result = await new ProxyHttpFetcher().FetchAsync(query, limits, tlsMode);
if (!result.Success)
{
diff --git a/src/WebContent/ProxyServerAllowlist.cs b/src/WebContent/ProxyServerAllowlist.cs
index 339f592..ceec228 100644
--- a/src/WebContent/ProxyServerAllowlist.cs
+++ b/src/WebContent/ProxyServerAllowlist.cs
@@ -18,9 +18,23 @@ internal sealed class ProxyServerAllowlist
private readonly Func>> _snapshotResolver;
private readonly Action _log;
private readonly object _sync = new object();
- private HashSet _origins; // null until the first successful resolve
+ private Snapshot _snapshot; // null until the first successful resolve
private DateTime _nextRefreshUtc = DateTime.MinValue;
+ /// One refresh's union: allowlisted origins, and the flagged subset whose
+ /// entries carry the #56 per-target accept-untrusted-certificate override.
+ private sealed class Snapshot
+ {
+ internal readonly HashSet Origins;
+ internal readonly HashSet UntrustedOrigins;
+
+ internal Snapshot(HashSet origins, HashSet untrustedOrigins)
+ {
+ Origins = origins;
+ UntrustedOrigins = untrustedOrigins;
+ }
+ }
+
internal ProxyServerAllowlist(Func>> snapshotResolver, Action log)
{
_snapshotResolver = snapshotResolver ?? throw new ArgumentNullException(nameof(snapshotResolver));
@@ -37,14 +51,14 @@ internal bool IsAllowed(Uri target, out string refuseReason)
lock (_sync)
{
- if (_origins == null)
+ if (_snapshot == null)
{
refuseReason = "The proxy fetcher has not been able to read the Configuration of proxied workspaces, so its allowlist gate is closed.";
return false;
}
var origin = ProxyPolicy.CanonicalOrigin(target);
- if (_origins.Contains(origin))
+ if (_snapshot.Origins.Contains(origin))
{
refuseReason = null;
return true;
@@ -55,6 +69,23 @@ internal bool IsAllowed(Uri target, out string refuseReason)
}
}
+ ///
+ /// Ticket #56: True only when the allowlist entry for THIS origin carries the admin's
+ /// accept-untrusted-certificate flag in at least one proxied workspace. An override for
+ /// one target never reaches a neighbouring origin, and while the gate is closed (never
+ /// resolved) no override exists — validation is the fail-closed default.
+ ///
+ internal bool AcceptsUntrustedCertificate(Uri target)
+ {
+ EnsureCurrent();
+
+ lock (_sync)
+ {
+ return _snapshot != null &&
+ _snapshot.UntrustedOrigins.Contains(ProxyPolicy.CanonicalOrigin(target));
+ }
+ }
+
private void EnsureCurrent()
{
var now = DateTime.UtcNow;
@@ -68,7 +99,7 @@ private void EnsureCurrent()
_nextRefreshUtc = now.Add(DefaultRefreshInterval);
}
- HashSet resolved;
+ Snapshot resolved;
try
{
resolved = BuildUnion(_snapshotResolver());
@@ -83,16 +114,17 @@ private void EnsureCurrent()
lock (_sync)
{
- _origins = resolved;
+ _snapshot = resolved;
}
}
- private static HashSet BuildUnion(IEnumerable> workspaceProperties)
+ private static Snapshot BuildUnion(IEnumerable> workspaceProperties)
{
var origins = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var untrustedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase);
if (workspaceProperties == null)
{
- return origins;
+ return new Snapshot(origins, untrustedOrigins);
}
foreach (var properties in workspaceProperties)
@@ -110,11 +142,15 @@ private static HashSet BuildUnion(IEnumerable
ProxyPolicy.TryNormalizeTargetParts(target.Scheme, target.Host, target.Port, out var origin))
{
origins.Add(origin);
+ if (target.AcceptUntrusted)
+ {
+ untrustedOrigins.Add(origin);
+ }
}
}
}
- return origins;
+ return new Snapshot(origins, untrustedOrigins);
}
}
}
diff --git a/src/WebContent/WorkspaceConfiguration.cs b/src/WebContent/WorkspaceConfiguration.cs
index 76a6259..a0a722d 100644
--- a/src/WebContent/WorkspaceConfiguration.cs
+++ b/src/WebContent/WorkspaceConfiguration.cs
@@ -273,5 +273,13 @@ internal class ProxyTargetConfiguration
[DataMember(Name = "port")]
public int Port { get; set; }
+
+ ///
+ /// Ticket #56: the admin's per-target "accept untrusted certificate" opt-in. Absent from
+ /// definitions saved before #56, which deserializes to false - the Event Server fetcher
+ /// validates that target's TLS chain normally.
+ ///
+ [DataMember(Name = "acceptUntrusted")]
+ public bool AcceptUntrusted { get; set; }
}
}
From 8efde5348b44b9a55a9d65db4904a32eb4256d12 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:39:17 -0700
Subject: [PATCH 016/187] feat(proxy): #56 config plumbing - proxy-target lines
carry the accept-untrusted flag token through parse/save/load roundtrip
(ProxyPolicy.TryParseTargetEntry/DescribeTargetEntry), MC page hint explains
the opt-in
---
src/WebContent.Tests/ProxyPolicyTests.cs | 37 ++++++++++++
src/WebContent/Admin/WorkspaceItemManager.cs | 41 ++++++++-----
src/WebContent/ProxyPolicy.cs | 61 ++++++++++++++++++++
src/WebContent/Strings.resx | 2 +-
4 files changed, 126 insertions(+), 15 deletions(-)
diff --git a/src/WebContent.Tests/ProxyPolicyTests.cs b/src/WebContent.Tests/ProxyPolicyTests.cs
index 50f600c..8805f81 100644
--- a/src/WebContent.Tests/ProxyPolicyTests.cs
+++ b/src/WebContent.Tests/ProxyPolicyTests.cs
@@ -71,5 +71,42 @@ public void EmptyAllowlistRefusesEveryRequest()
Assert.False(ProxyPolicy.IsRequestAllowed(new string[0], new Uri("https://cam2.local:8443/")));
Assert.False(ProxyPolicy.IsRequestAllowed(null, new Uri("https://cam2.local:8443/")));
}
+
+ // #56: an admin line is one origin plus an optional accept-untrusted-certificate flag
+ // token. Everything about the entry - origin and flag - travels with the entry.
+
+ [Theory]
+ [InlineData("https://10.0.0.5:8443", "https://10.0.0.5:8443", false)]
+ [InlineData("https://10.0.0.5:8443 accept-untrusted", "https://10.0.0.5:8443", true)]
+ [InlineData("https://cam2.local/ ACCEPT-UNTRUSTED", "https://cam2.local", true)]
+ [InlineData("accept-untrusted Cam2.Local:8443", "https://cam2.local:8443", true)]
+ [InlineData(" cam2.local:8443 accept-untrusted ", "https://cam2.local:8443", true)]
+ public void TargetLineCarriesOneOriginAndAnOptionalUntrustedCertificateFlag(
+ string line, string expectedOrigin, bool expectedFlag)
+ {
+ Assert.True(ProxyPolicy.TryParseTargetEntry(line, out var origin, out var acceptUntrusted));
+ Assert.Equal(expectedOrigin, origin);
+ Assert.Equal(expectedFlag, acceptUntrusted);
+ }
+
+ [Theory]
+ [InlineData("https://cam2.local:8443 allow-all")]
+ [InlineData("https://cam2.local:8443 accept_untrusted")]
+ [InlineData("https://a.test https://b.test")]
+ [InlineData("accept-untrusted")]
+ [InlineData("ftp://cam2 accept-untrusted")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void MalformedTargetLineIsRejected(string line)
+ {
+ Assert.False(ProxyPolicy.TryParseTargetEntry(line, out _, out _));
+ }
+
+ [Fact]
+ public void StoredEntryRendersBackAsTheAdminLineThatCreatedIt()
+ {
+ Assert.Equal("https://cam2.local:8443", ProxyPolicy.DescribeTargetEntry("https://cam2.local:8443", false));
+ Assert.Equal("https://cam2.local:8443 accept-untrusted", ProxyPolicy.DescribeTargetEntry("https://cam2.local:8443", true));
+ }
}
}
diff --git a/src/WebContent/Admin/WorkspaceItemManager.cs b/src/WebContent/Admin/WorkspaceItemManager.cs
index 8382768..c67c3e8 100644
--- a/src/WebContent/Admin/WorkspaceItemManager.cs
+++ b/src/WebContent/Admin/WorkspaceItemManager.cs
@@ -303,17 +303,21 @@ private void SetOrRemoveProperty(Item item, string key, string value)
///
/// Ticket #52: parses the admin's one-origin-per-line proxy targets into canonical
- /// scheme/host/port records. An invalid line rejects the save whatever the proxy toggle
- /// state (same posture as the home address), duplicates and other overlapping entries
- /// collapse onto one record, and an enabled proxy with no targets at all is rejected so
- /// the toggle can never mean "allow nothing" by accident. A disabled proxy keeps the
- /// targets parsed and stored anyway - turning the proxy off must not erase the allowlist
- /// the next admin will need (MC-9 in the verification matrix).
+ /// scheme/host/port records. Since #56 a line may carry the accept-untrusted-certificate
+ /// flag token, stored on the entry itself. An invalid line rejects the save whatever the
+ /// proxy toggle state (same posture as the home address), duplicates and other overlapping
+ /// entries collapse onto one record (any flagged duplicate flags the origin), and an
+ /// enabled proxy with no targets at all is rejected so the toggle can never mean "allow
+ /// nothing" by accident. A disabled proxy keeps the targets parsed and stored anyway -
+ /// turning the proxy off must not erase the allowlist the next admin will need (MC-9 in
+ /// the verification matrix).
///
private static bool TryBuildProxyTargets(string itemName, FQID itemFqid, string targetsText, bool proxyEnabled, out List targets)
{
targets = new List();
+ var origins = new List();
var seenOrigins = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var untrustedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in (targetsText ?? string.Empty).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
@@ -323,24 +327,33 @@ private static bool TryBuildProxyTargets(string itemName, FQID itemFqid, string
continue;
}
- if (!ProxyPolicy.TryNormalizeTarget(line, out var origin))
+ if (!ProxyPolicy.TryParseTargetEntry(line, out var origin, out var acceptUntrusted))
{
EnvironmentManager.Instance.Log(true, nameof(WorkspaceItemManager), $"Rejected save for workspace '{itemName}' (FQID: {itemFqid}): invalid proxy target '{line}'.");
MessageBox.Show(string.Format(Strings.InvalidProxyTargetMessage, line), Strings.InvalidAddressTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return false;
}
- if (!seenOrigins.Add(origin))
+ if (seenOrigins.Add(origin))
{
- continue;
+ origins.Add(origin);
+ }
+
+ if (acceptUntrusted)
+ {
+ untrustedOrigins.Add(origin);
}
+ }
- var originUri = new Uri(origin);
+ foreach (var origin in origins)
+ {
+ var parsedOrigin = new Uri(origin);
targets.Add(new ProxyTargetConfiguration
{
- Scheme = originUri.Scheme,
- Host = originUri.Host,
- Port = originUri.Port
+ Scheme = parsedOrigin.Scheme,
+ Host = parsedOrigin.Host,
+ Port = parsedOrigin.Port,
+ AcceptUntrusted = untrustedOrigins.Contains(origin),
});
}
@@ -370,7 +383,7 @@ private static string DescribeProxyTargets(List target
{
if (target != null && ProxyPolicy.TryNormalizeTargetParts(target.Scheme, target.Host, target.Port, out var origin))
{
- lines.Add(origin);
+ lines.Add(ProxyPolicy.DescribeTargetEntry(origin, target.AcceptUntrusted));
}
}
diff --git a/src/WebContent/ProxyPolicy.cs b/src/WebContent/ProxyPolicy.cs
index e6e99b2..79649c7 100644
--- a/src/WebContent/ProxyPolicy.cs
+++ b/src/WebContent/ProxyPolicy.cs
@@ -96,6 +96,67 @@ private static string FormatOrigin(string scheme, string host, int port)
return $"{scheme}://{host}{portPart}";
}
+ ///
+ /// #56: the optional per-entry flag token marking "accept an untrusted TLS certificate for
+ /// this target". The flag is stored with the entry, never as a global toggle.
+ ///
+ internal const string AcceptUntrustedFlag = "accept-untrusted";
+
+ ///
+ /// Parses one admin proxy-target line (#56): an origin entry plus the optional
+ /// token, in either order. An unknown token or a second
+ /// origin on the line rejects the whole entry — a typo must not silently drop the flag or
+ /// smuggle an extra origin past the admin.
+ ///
+ internal static bool TryParseTargetEntry(string line, out string normalized, out bool acceptUntrusted)
+ {
+ normalized = null;
+ acceptUntrusted = false;
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ return false;
+ }
+
+ string originEntry = null;
+ foreach (var token in line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (string.Equals(token, AcceptUntrustedFlag, StringComparison.OrdinalIgnoreCase))
+ {
+ acceptUntrusted = true;
+ continue;
+ }
+
+ if (originEntry != null)
+ {
+ return false;
+ }
+
+ originEntry = token;
+ }
+
+ if (originEntry == null)
+ {
+ return false;
+ }
+
+ if (!TryNormalizeTarget(originEntry, out normalized))
+ {
+ normalized = null;
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Renders a stored entry back as the admin-page line that created it (#56): the canonical
+ /// origin plus the flag token only when the entry carries the override.
+ ///
+ internal static string DescribeTargetEntry(string canonicalOrigin, bool acceptUntrusted)
+ {
+ return acceptUntrusted ? $"{canonicalOrigin} {AcceptUntrustedFlag}" : canonicalOrigin;
+ }
+
///
/// True when the request origin already appears in the allowlisted canonical origins —
/// exact origin match, no wildcards, no path or prefix semantics.
diff --git a/src/WebContent/Strings.resx b/src/WebContent/Strings.resx
index aace265..de1a886 100644
--- a/src/WebContent/Strings.resx
+++ b/src/WebContent/Strings.resx
@@ -238,7 +238,7 @@
Proxy targets
- One target per line, for example https://10.0.0.5:8443. Only allowlisted targets can be reached through the proxy.
+ One target per line, for example https://10.0.0.5:8443. Only allowlisted targets can be reached through the proxy. Append accept-untrusted to a target's line to accept that device's untrusted TLS certificate; without it, certificates are validated normally.
"{0}" is not a valid proxy target. Enter a target with scheme, host and port, for example https://10.0.0.5:8443.
From 8cfb2ba37fcab5773b2512e26c76ffbd1aa1fe41 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:42:35 -0700
Subject: [PATCH 017/187] feat(proxy): #56 forward policy allowlist-gates
CONNECT - allowlisted https origins terminate the tunnel at the loopback
listener (ADR-0002), other CONNECTs get the 403 origin posture; codec
resolves the authority to its https origin
---
.../ProxyForwardCodecTests.cs | 7 ++-
.../ProxyForwardPolicyTests.cs | 63 ++++++++++++++++---
src/WebContent/ProxyForwardCodec.cs | 37 +++++++++--
src/WebContent/ProxyForwardPolicy.cs | 20 +++++-
4 files changed, 110 insertions(+), 17 deletions(-)
diff --git a/src/WebContent.Tests/ProxyForwardCodecTests.cs b/src/WebContent.Tests/ProxyForwardCodecTests.cs
index a360f0f..aa9de48 100644
--- a/src/WebContent.Tests/ProxyForwardCodecTests.cs
+++ b/src/WebContent.Tests/ProxyForwardCodecTests.cs
@@ -56,8 +56,9 @@ public void RefusesNonHttpSchemeTargets()
[Fact]
public void RecognizesConnectRequestsByTheirAuthorityTarget()
{
- // Chromium opens https routes with CONNECT host:port. The codec parses it out of the
- // head so the decision layer can refuse it as a refused tunnel (502), not a 400.
+ // Chromium opens https routes with CONNECT host:port. The codec resolves the authority
+ // to the https origin (#56) so the decision layer can allowlist-gate tunnel
+ // termination instead of answering a flat 400.
var head = "CONNECT portal.example.com:443 HTTP/1.1\r\nProxy-Connection: keep-alive\r\n\r\n";
var parsed = ProxyForwardCodec.TryParseRequestHead(head, out var request, out var rejectReason);
@@ -65,7 +66,7 @@ public void RecognizesConnectRequestsByTheirAuthorityTarget()
Assert.True(parsed, rejectReason);
Assert.Equal("CONNECT", request.Method);
Assert.Equal("portal.example.com:443", request.RawTarget);
- Assert.Null(request.Uri);
+ Assert.Equal("https://portal.example.com", ProxyPolicy.CanonicalOrigin(request.Uri));
}
[Fact]
diff --git a/src/WebContent.Tests/ProxyForwardPolicyTests.cs b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
index 833dd2e..3b0f3ef 100644
--- a/src/WebContent.Tests/ProxyForwardPolicyTests.cs
+++ b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
@@ -83,16 +83,65 @@ public void RefusesWriteMethodsToNonAllowlistedOriginsWith403()
}
[Fact]
- public void RefusesConnectTunnelsWithAnExplanation()
+ public void TerminatesConnectTunnelsToAllowlistedHttpsOrigins()
{
- // https would reach the listener as CONNECT; a CONNECT-less forwarder cannot relay it,
- // and the browser must never be allowed to go direct instead.
- var head = "CONNECT portal.example.com:443 HTTP/1.1\r\nProxy-Connection: keep-alive\r\n\r\n";
+ // #56: an allowlisted https origin gets its CONNECT accepted and terminated at the
+ // loopback listener (ADR-0002) - the decrypted requests then ride the fetch pipeline.
+ var head = "CONNECT cam1.test:8443 HTTP/1.1\r\nProxy-Connection: keep-alive\r\n\r\n";
- var decision = ProxyForwardPolicy.Decide(new[] { "https://portal.example.com" }, head, out var request, out var rejectReason);
+ var decision = ProxyForwardPolicy.Decide(new[] { "https://cam1.test:8443" }, head, out var request, out var rejectReason);
- Assert.Equal(ProxyForwardDisposition.ConnectRefused, decision);
- Assert.Contains("portal.example.com:443", rejectReason);
+ Assert.Equal(ProxyForwardDisposition.ConnectTerminate, decision);
+ Assert.Equal("https://cam1.test:8443", ProxyPolicy.CanonicalOrigin(request.Uri));
+ Assert.Null(rejectReason);
+ }
+
+ [Fact]
+ public void TerminatesConnectToTheSchemeDefaultPortOfAnAllowlistedOrigin()
+ {
+ // The canonical allowlist drops the default port; the browser CONNECTs to :443 -
+ // the same origin must match either spelling.
+ var head = "CONNECT portal.example.com:443 HTTP/1.1\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(new[] { "https://portal.example.com" }, head, out var request, out _);
+
+ Assert.Equal(ProxyForwardDisposition.ConnectTerminate, decision);
+ Assert.Equal("https://portal.example.com", ProxyPolicy.CanonicalOrigin(request.Uri));
+ }
+
+ [Fact]
+ public void RefusesConnectTunnelsToOriginsNoWorkspaceAllowlists()
+ {
+ // A CONNECT to an origin no proxied workspace allowlists is refused with the same
+ // 403 posture as any other request, and the browser is never let go direct.
+ var head = "CONNECT somewhere.else.test:443 HTTP/1.1\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.OriginNotAllowed, decision);
+ Assert.Contains("somewhere.else.test", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesConnectTunnelsToHttpOrigins()
+ {
+ // CONNECT speaks a TLS tunnel; an http allowlist entry must not open one for it.
+ var head = "CONNECT 10.0.0.5:8080 HTTP/1.1\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out _, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.OriginNotAllowed, decision);
+ Assert.Contains("10.0.0.5", rejectReason);
+ }
+
+ [Theory]
+ [InlineData("CONNECT not-a-host:port HTTP/1.1\r\n\r\n")]
+ [InlineData("CONNECT HTTP/1.1\r\n\r\n")]
+ public void RejectsMalformedConnectTargets(string head)
+ {
+ var decision = ProxyForwardPolicy.Decide(new[] { "https://cam1.test:8443" }, head, out var request, out _);
+
+ Assert.Equal(ProxyForwardDisposition.BadRequest, decision);
}
[Fact]
diff --git a/src/WebContent/ProxyForwardCodec.cs b/src/WebContent/ProxyForwardCodec.cs
index c0e148c..22a47f5 100644
--- a/src/WebContent/ProxyForwardCodec.cs
+++ b/src/WebContent/ProxyForwardCodec.cs
@@ -18,7 +18,11 @@ internal sealed class ProxyForwardedRequest
/// The request-target exactly as it arrived; CONNECT tunnels carry an authority here.
internal string RawTarget;
- /// Absolute http(s) uri for forwarded requests; null for CONNECT tunnels.
+ ///
+ /// Absolute uri for forwarded requests; for CONNECT (#56) the https origin the tunnel
+ /// targets, resolved from the authority — the inner TLS-terminated requests extend it
+ /// with paths.
+ ///
internal Uri Uri;
/// Request headers, first value per name, original casing preserved.
@@ -35,6 +39,18 @@ internal sealed class ProxyForwardedRequest
internal static class ProxyForwardCodec
{
internal static bool TryParseRequestHead(string head, out ProxyForwardedRequest request, out string rejectReason)
+ {
+ return TryParseRequestHead(head, null, out request, out rejectReason);
+ }
+
+ ///
+ /// Parse a request head, optionally inside a TLS tunnel whose origin the caller
+ /// terminated (#56). With a tunnel origin the request-target is origin-form
+ /// ("GET /path HTTP/1.1") and combines with the tunnel origin — the tunnel IS the origin
+ /// the browser believes it is talking to, so the Host header is never believed. Without a
+ /// tunnel origin an absolute-form target stays required, exactly as an HTTP proxy request.
+ ///
+ internal static bool TryParseRequestHead(string head, Uri tunnelOrigin, out ProxyForwardedRequest request, out string rejectReason)
{
request = null;
rejectReason = null;
@@ -59,10 +75,23 @@ internal static bool TryParseRequestHead(string head, out ProxyForwardedRequest
RawTarget = requestLine[1],
};
- // CONNECT carries an authority ("host:port"), not a uri. Parse it through so the
- // decision layer can answer a refused tunnel (502) instead of a malformed-request 400.
+ // CONNECT carries an authority ("host:port"), not a uri. #56 resolves it to the https
+ // origin the tunnel would open so the decision layer can allowlist-gate the tunnel
+ // termination; a malformed authority is a malformed request.
Uri uri = null;
- if (parsed.Method != "CONNECT" && !Uri.TryCreate(requestLine[1], UriKind.Absolute, out uri))
+ if (parsed.Method == "CONNECT")
+ {
+ // A CONNECT request-target is strictly an authority - a path or query in it is
+ // not something a browser sends and is rejected rather than guessed at.
+ if (requestLine[1].IndexOf('/') >= 0 ||
+ !Uri.TryCreate($"https://{requestLine[1]}", UriKind.Absolute, out uri) ||
+ !ProxyPolicy.IsHttpUri(uri))
+ {
+ rejectReason = $"The CONNECT target '{requestLine[1]}' was not a host:port authority.";
+ return false;
+ }
+ }
+ else if (!Uri.TryCreate(requestLine[1], UriKind.Absolute, out uri))
{
rejectReason = $"The proxy request target '{requestLine[1]}' was not an absolute uri.";
return false;
diff --git a/src/WebContent/ProxyForwardPolicy.cs b/src/WebContent/ProxyForwardPolicy.cs
index 8cc8abf..f11bdc2 100644
--- a/src/WebContent/ProxyForwardPolicy.cs
+++ b/src/WebContent/ProxyForwardPolicy.cs
@@ -18,7 +18,13 @@ internal enum ProxyForwardDisposition
/// Verb outside the browser-flow method set (#55) — 405.
MethodNotAllowed,
- /// CONNECT tunnel (https through a CONNECT-less forwarder) — 502.
+ ///
+ /// CONNECT to an allowlisted https origin (#56): the loopback listener accepts the tunnel
+ /// and terminates TLS with an ephemeral local certificate (ADR-0002).
+ ///
+ ConnectTerminate,
+
+ /// CONNECT that cannot be terminated here — 502, never taken direct.
ConnectRefused,
}
@@ -64,8 +70,16 @@ internal static ProxyForwardDisposition Decide(
if (request.Method == "CONNECT")
{
- rejectReason = $"The proxy does not open CONNECT tunnels; https routes like '{request.RawTarget}' cannot be relayed and are never taken direct.";
- return ProxyForwardDisposition.ConnectRefused;
+ // #56: an allowlisted https origin gets the tunnel TERMINATED at this listener
+ // (ephemeral local certificate, ADR-0002); anything else is refused with the same
+ // origin posture as a plain request, and the browser is never let go direct.
+ if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, request.Uri))
+ {
+ rejectReason = $"The CONNECT target '{ProxyPolicy.CanonicalOrigin(request.Uri)}' is not an allowlisted https proxy origin; the tunnel is refused and the browser is never taken direct.";
+ return ProxyForwardDisposition.OriginNotAllowed;
+ }
+
+ return ProxyForwardDisposition.ConnectTerminate;
}
if (!ForwardedMethods.Contains(request.Method))
From 485641dc1d726567a208fcd0a6e2bace5892e5e7 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:44:28 -0700
Subject: [PATCH 018/187] feat(proxy): #56 codec and policy accept origin-form
in-tunnel requests resolved against the listener-terminated tunnel origin;
absolute-form smuggling and nested CONNECTs are refused
---
.../ProxyForwardCodecTests.cs | 37 +++++++++++++++++++
.../ProxyForwardPolicyTests.cs | 26 +++++++++++++
src/WebContent/ProxyForwardCodec.cs | 13 +++++++
src/WebContent/ProxyForwardPolicy.cs | 23 +++++++++++-
4 files changed, 98 insertions(+), 1 deletion(-)
diff --git a/src/WebContent.Tests/ProxyForwardCodecTests.cs b/src/WebContent.Tests/ProxyForwardCodecTests.cs
index aa9de48..20c96de 100644
--- a/src/WebContent.Tests/ProxyForwardCodecTests.cs
+++ b/src/WebContent.Tests/ProxyForwardCodecTests.cs
@@ -177,5 +177,42 @@ public void RendersErrorResponseWithProxyExplanationBody()
Assert.Contains("The origin 'http://evil.test' is not allowlisted.", rendered);
Assert.Matches("(?i)Connection: close", rendered);
}
+ [Fact]
+ public void InnerTlsRequestCombinesItsOriginFormPathWithTheTunnelOrigin()
+ {
+ // #56: inside a terminated TLS tunnel Chromium speaks ordinary origin-form HTTP.
+ // The tunnel origin — which the listener itself established — supplies the scheme,
+ // host, and port; the Host header is never believed.
+ var head = "GET /live/index.html HTTP/1.1\r\nHost: cam1.test:8443\r\nAccept: */*\r\n\r\n";
+
+ var parsed = ProxyForwardCodec.TryParseRequestHead(head, new Uri("https://cam1.test:8443"), out var request, out var rejectReason);
+
+ Assert.True(parsed, rejectReason);
+ Assert.Equal("/live/index.html", request.RawTarget);
+ Assert.Equal("https://cam1.test:8443/live/index.html", request.Uri.AbsoluteUri);
+ }
+
+ [Fact]
+ public void InnerTlsRequestWithAbsoluteFormAtAnotherOriginIsRejected()
+ {
+ // An absolute target inside the tunnel that names anything but the tunnel's own
+ // origin is a smuggling attempt, not a request the tunnel can serve.
+ var head = "GET http://evil.test/steal HTTP/1.1\r\nHost: evil.test\r\n\r\n";
+
+ var parsed = ProxyForwardCodec.TryParseRequestHead(head, new Uri("https://cam1.test:8443"), out var request, out _);
+
+ Assert.False(parsed);
+ Assert.Null(request);
+ }
+
+ [Fact]
+ public void PlainProxyRequestWithoutATunnelStillRequiresAbsoluteForm()
+ {
+ var head = "GET /monitor/live HTTP/1.1\r\nHost: 10.0.0.5:8080\r\n\r\n";
+
+ var parsed = ProxyForwardCodec.TryParseRequestHead(head, null, out _, out _);
+
+ Assert.False(parsed);
+ }
}
}
diff --git a/src/WebContent.Tests/ProxyForwardPolicyTests.cs b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
index 3b0f3ef..2a5ab60 100644
--- a/src/WebContent.Tests/ProxyForwardPolicyTests.cs
+++ b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
@@ -152,5 +152,31 @@ public void RejectsMalformedProxyRequestLines()
Assert.Equal(ProxyForwardDisposition.BadRequest, decision);
Assert.Null(request);
}
+ [Fact]
+ public void InnerTunnelRequestForwardsAgainstTheTunnelOrigin()
+ {
+ // The decrypted inner request of a terminated tunnel (#56): origin-form path, and the
+ // origin gate runs on the tunnel origin, not on any header the client types.
+ var head = "GET /live/index.html HTTP/1.1\r\nHost: cam1.test:8443\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(
+ new[] { "https://cam1.test:8443" }, head, new Uri("https://cam1.test:8443"), out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.Forward, decision);
+ Assert.Equal("https://cam1.test:8443/live/index.html", request.Uri.AbsoluteUri);
+ Assert.Null(rejectReason);
+ }
+
+ [Fact]
+ public void NestedConnectInsideATerminatedTunnelIsRefused()
+ {
+ var head = "CONNECT evil.test:443 HTTP/1.1\r\n\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(
+ new[] { "https://cam1.test:8443" }, head, new Uri("https://cam1.test:8443"), out _, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.MethodNotAllowed, decision);
+ Assert.Contains("CONNECT", rejectReason);
+ }
}
}
diff --git a/src/WebContent/ProxyForwardCodec.cs b/src/WebContent/ProxyForwardCodec.cs
index 22a47f5..d15d62d 100644
--- a/src/WebContent/ProxyForwardCodec.cs
+++ b/src/WebContent/ProxyForwardCodec.cs
@@ -91,6 +91,19 @@ internal static bool TryParseRequestHead(string head, Uri tunnelOrigin, out Prox
return false;
}
}
+ else if (tunnelOrigin != null)
+ {
+ // Inside a terminated tunnel the tunnel IS the origin: origin-form targets
+ // resolve against it, and an absolute-form target may only name the tunnel's
+ // own origin (never smuggle another through the accepted gate).
+ if (!Uri.TryCreate(tunnelOrigin, requestLine[1], out uri) ||
+ !ProxyPolicy.IsHttpUri(uri) ||
+ ProxyPolicy.CanonicalOrigin(uri) != ProxyPolicy.CanonicalOrigin(tunnelOrigin))
+ {
+ rejectReason = $"The in-tunnel request target '{requestLine[1]}' does not belong to the tunnel origin '{tunnelOrigin.OriginalString}'.";
+ return false;
+ }
+ }
else if (!Uri.TryCreate(requestLine[1], UriKind.Absolute, out uri))
{
rejectReason = $"The proxy request target '{requestLine[1]}' was not an absolute uri.";
diff --git a/src/WebContent/ProxyForwardPolicy.cs b/src/WebContent/ProxyForwardPolicy.cs
index f11bdc2..32a0828 100644
--- a/src/WebContent/ProxyForwardPolicy.cs
+++ b/src/WebContent/ProxyForwardPolicy.cs
@@ -63,13 +63,34 @@ internal static ProxyForwardDisposition Decide(
out ProxyForwardedRequest request,
out string rejectReason)
{
- if (!ProxyForwardCodec.TryParseRequestHead(requestHead, out request, out rejectReason))
+ return Decide(canonicalTargets, requestHead, null, out request, out rejectReason);
+ }
+
+ ///
+ /// Decide a request head, optionally one read from inside a TLS tunnel the listener itself
+ /// terminated (#56). A tunnel origin makes origin-form targets legal — resolved against
+ /// the tunnel — and outlaws a nested CONNECT.
+ ///
+ internal static ProxyForwardDisposition Decide(
+ IEnumerable canonicalTargets,
+ string requestHead,
+ Uri tunnelOrigin,
+ out ProxyForwardedRequest request,
+ out string rejectReason)
+ {
+ if (!ProxyForwardCodec.TryParseRequestHead(requestHead, tunnelOrigin, out request, out rejectReason))
{
return ProxyForwardDisposition.BadRequest;
}
if (request.Method == "CONNECT")
{
+ if (tunnelOrigin != null)
+ {
+ rejectReason = "A CONNECT inside an already-terminated TLS tunnel is refused; the tunnel's origin is the only endpoint.";
+ return ProxyForwardDisposition.MethodNotAllowed;
+ }
+
// #56: an allowlisted https origin gets the tunnel TERMINATED at this listener
// (ephemeral local certificate, ADR-0002); anything else is refused with the same
// origin posture as a plain request, and the browser is never let go direct.
From 1a7f1ef69179c4d513af6a0ec10a1bb1df371ca6 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 11:53:56 -0700
Subject: [PATCH 019/187] feat(proxy): #56 connection flow terminates
allowlisted CONNECT tunnels - 200 handshake, inner request loop against the
tunnel origin through the decision/fetch/render pipeline, pipelined remainder
bytes carried across requests
---
.../ProxyForwardConnectionTests.cs | 146 +++++++++++-
src/WebContent/ProxyForwardConnection.cs | 208 +++++++++++++++---
2 files changed, 322 insertions(+), 32 deletions(-)
diff --git a/src/WebContent.Tests/ProxyForwardConnectionTests.cs b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
index 9ff37cd..3f1a868 100644
--- a/src/WebContent.Tests/ProxyForwardConnectionTests.cs
+++ b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
@@ -244,12 +244,154 @@ public async Task MapsFetchTimeoutsTo504()
Assert.StartsWith("HTTP/1.1 504 Gateway Timeout\r\n", response);
}
- private static async Task Handle(string request, Func> fetch)
+ // #56: allowlisted CONNECT tunnels are terminated at the listener; the decrypted inner
+ // requests ride the same decision/fetch/render flow. The fake unwrap seam stands in for
+ // the ephemeral-certificate SslStream, which its own tests and the live harness cover.
+
+ [Fact]
+ public async Task TerminatesAllowlistedConnectAndServesTheInnerRequestAgainstTheTunnelOrigin()
+ {
+ var seen = new List();
+ Func> fetch = query =>
+ {
+ seen.Add(query);
+ return Task.FromResult(new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Headers = new List { new ProxyFetchHeader { Name = "Content-Type", Value = "text/html" } },
+ BodyLength = 6,
+ Body = Encoding.UTF8.GetBytes("secure"),
+ });
+ };
+
+ var request =
+ "CONNECT cam1.test:8443 HTTP/1.1\r\n" +
+ "Proxy-Connection: keep-alive\r\n" +
+ "\r\n" +
+ "GET /live/index.html HTTP/1.1\r\n" +
+ "Host: cam1.test:8443\r\n" +
+ "\r\n";
+
+ var response = await HandleTunnel(request, new[] { "https://cam1.test:8443" }, fetch);
+
+ Assert.StartsWith("HTTP/1.1 200 Connection established\r\n\r\n", response);
+ Assert.Equal(1, seen.Count);
+ Assert.Equal("https://cam1.test:8443/live/index.html", seen[0].Uri.AbsoluteUri);
+ Assert.Contains("HTTP/1.1 200 OK\r\n", response);
+ Assert.Contains("\r\n\r\nsecure", response);
+ }
+
+ [Fact]
+ public async Task ServesSeveralInnerRequestsOverOneTerminatedTunnel()
+ {
+ var seen = new List();
+ Func> fetch = query =>
+ {
+ seen.Add(query.Uri.AbsoluteUri);
+ return Task.FromResult(new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ BodyLength = 0,
+ Body = new byte[0],
+ });
+ };
+
+ var request =
+ "CONNECT cam1.test:8443 HTTP/1.1\r\n\r\n" +
+ "GET /one HTTP/1.1\r\nHost: cam1.test:8443\r\n\r\n" +
+ "POST /two HTTP/1.1\r\nHost: cam1.test:8443\r\nContent-Length: 4\r\n\r\nbody";
+
+ var response = await HandleTunnel(request, new[] { "https://cam1.test:8443" }, fetch);
+
+ Assert.Equal(
+ new[] { "https://cam1.test:8443/one", "https://cam1.test:8443/two" },
+ seen);
+ Assert.Equal(2, CountLines(response, "HTTP/1.1 200 OK\r\n"));
+ }
+
+ [Fact]
+ public async Task RefusesVerbsInsideTheTunnelAfterTheHandshakeWithoutTouchingTheFetcher()
+ {
+ var fetchCalled = false;
+ Func> fetch = query =>
+ {
+ fetchCalled = true;
+ return Task.FromResult(new ProxyFetchResult { Success = false });
+ };
+
+ var request =
+ "CONNECT cam1.test:8443 HTTP/1.1\r\n\r\n" +
+ "TRACE /debug HTTP/1.1\r\nHost: cam1.test:8443\r\n\r\n";
+
+ var response = await HandleTunnel(request, new[] { "https://cam1.test:8443" }, fetch);
+
+ Assert.False(fetchCalled);
+ Assert.StartsWith("HTTP/1.1 200 Connection established\r\n\r\n", response);
+ Assert.Contains("HTTP/1.1 405", response);
+ }
+
+ [Fact]
+ public async Task RefusesConnectWith502WhenNoTlsTerminationIsAvailable()
+ {
+ // Defensive wiring path: an allowlisted CONNECT the listener cannot terminate (no
+ // ephemeral-certificate plumbing) is refused outright - never tunnelled, never direct.
+ var response = await Handle(
+ "CONNECT cam1.test:8443 HTTP/1.1\r\n\r\n",
+ query => Task.FromResult(new ProxyFetchResult { Success = false }),
+ new[] { "https://cam1.test:8443" });
+
+ Assert.StartsWith("HTTP/1.1 502 Bad Gateway\r\n", response);
+ Assert.Contains("cam1.test", response);
+ }
+
+ private static async Task Handle(
+ string request,
+ Func> fetch,
+ string[] allowlist = null)
{
using (var input = new MemoryStream(Encoding.UTF8.GetBytes(request)))
using (var output = new MemoryStream())
{
- await ProxyForwardConnection.HandleAsync(input, output, Allowlist, fetch, message => { });
+ await ProxyForwardConnection.HandleAsync(input, output, allowlist ?? Allowlist, fetch, message => { });
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+ }
+
+ private static async Task HandleTunnel(
+ string request,
+ string[] allowlist,
+ Func> fetch)
+ {
+ using (var input = new MemoryStream(Encoding.UTF8.GetBytes(request)))
+ using (var output = new MemoryStream())
+ {
+ // Identity unwrap: the CONNECT head's pipelined leftover plus the rest of the
+ // stream becomes the decrypted tunnel stream - exactly the rebase the SslStream
+ // wrapper performs in production (the ClientHello can ride the CONNECT segment).
+ await ProxyForwardConnection.HandleAsync(
+ input, output, allowlist, fetch, message => { },
+ (stream, leftover, tunnelOrigin) =>
+ {
+ var combined = new MemoryStream();
+ if (leftover != null && leftover.Length > 0)
+ {
+ combined.Write(leftover, 0, leftover.Length);
+ }
+
+ var buffer = new byte[4096];
+ int read;
+ while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ combined.Write(buffer, 0, read);
+ }
+
+ combined.Position = 0;
+ return Task.FromResult(combined);
+ });
return Encoding.UTF8.GetString(output.ToArray());
}
}
diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs
index 97a0302..9075e03 100644
--- a/src/WebContent/ProxyForwardConnection.cs
+++ b/src/WebContent/ProxyForwardConnection.cs
@@ -27,12 +27,31 @@ internal static class ProxyForwardConnection
///
internal const int MaxRequestBodyBytes = 1024 * 1024;
- internal static async Task HandleAsync(
+ internal static Task HandleAsync(
Stream input,
Stream output,
System.Collections.Generic.IEnumerable canonicalTargets,
Func> fetch,
Action log)
+ {
+ return HandleAsync(input, output, canonicalTargets, fetch, log, null);
+ }
+
+ ///
+ /// Optional tunnel-termination seam (#56): given the raw socket stream, any bytes the
+ /// head reader already pulled past the CONNECT head (a TLS ClientHello can pipeline into
+ /// the same segment), and the allowlisted https origin, wrap the stream in TLS with an
+ /// ephemeral local certificate and return the decrypted stream (null when termination is
+ /// impossible). The pure flow stays testable with an identity seam; production passes
+ /// ProxyLoopbackTls .
+ ///
+ internal static async Task HandleAsync(
+ Stream input,
+ Stream output,
+ System.Collections.Generic.IEnumerable canonicalTargets,
+ Func> fetch,
+ Action log,
+ Func> unwrapTunnelTls)
{
var head = await ReadRequestHeadAsync(input);
if (head == null)
@@ -60,18 +79,127 @@ internal static async Task HandleAsync(
await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(405, "Method Not Allowed", rejectReason));
return;
+ case ProxyForwardDisposition.ConnectTerminate:
+ if (unwrapTunnelTls == null)
+ {
+ // Defensive wiring: no ephemeral-certificate plumbing on this listener. The
+ // tunnel is refused outright - never blind-tunnelled, never taken direct.
+ var reason = $"The CONNECT to '{request.RawTarget}' cannot be terminated by this proxy; https targets are fetched by the VMS server over tunnels this listener terminates.";
+ log(reason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", reason));
+ return;
+ }
+
+ log($"Proxy tunnel to '{request.RawTarget}' allowlisted; terminating TLS at the loopback listener.");
+ await WriteAsync(output, Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"));
+
+ Stream tunnel;
+ try
+ {
+ tunnel = await unwrapTunnelTls(input, head.Leftover, request.Uri);
+ }
+ catch (Exception exception)
+ {
+ log($"Proxy TLS termination for '{request.RawTarget}' failed: {exception.Message}");
+ return;
+ }
+
+ if (tunnel == null)
+ {
+ log($"Proxy TLS termination for '{request.RawTarget}' produced no tunnel; the connection is closed.");
+ return;
+ }
+
+ await ServeTunnelRequestsAsync(tunnel, output, canonicalTargets, request.Uri, fetch, log);
+ return;
+
case ProxyForwardDisposition.ConnectRefused:
log(rejectReason);
await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", rejectReason + " The workspace proxy supports http(s) allowlisted targets fetched by the VMS server; https pages need a CONNECT tunnel this proxy does not provide."));
return;
}
+ await ServeForwardedRequestAsync(request, head.Leftover, input, output, fetch, log);
+ }
+
+ ///
+ /// The request loop inside one terminated tunnel (#56): Chromium pipelines several inner
+ /// requests over a single TLS connection, each origin-form against the tunnel origin that
+ /// the allowlist already admitted when the tunnel was accepted.
+ ///
+ private static async Task ServeTunnelRequestsAsync(
+ Stream tunnel,
+ Stream output,
+ System.Collections.Generic.IEnumerable canonicalTargets,
+ Uri tunnelOrigin,
+ Func> fetch,
+ Action log)
+ {
+ var pending = new byte[0];
+ while (true)
+ {
+ var head = await ReadRequestHeadAsync(tunnel, pending);
+ if (head == null)
+ {
+ return;
+ }
+
+ var disposition = ProxyForwardPolicy.Decide(canonicalTargets, head.Text, tunnelOrigin, out var request, out var rejectReason);
+ switch (disposition)
+ {
+ case ProxyForwardDisposition.Forward:
+ break;
+
+ case ProxyForwardDisposition.BadRequest:
+ // A head this broken leaves the stream position untrustworthy: answer and close.
+ log(rejectReason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", rejectReason));
+ return;
+
+ case ProxyForwardDisposition.MethodNotAllowed:
+ log(rejectReason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(405, "Method Not Allowed", rejectReason));
+ pending = head.Leftover;
+ continue;
+
+ default:
+ // OriginNotAllowed inside a tunnel means the inner target drifted off the
+ // accepted origin (codec already blocks that) - refuse and keep the tunnel.
+ log(rejectReason ?? $"In-tunnel request '{request?.RawTarget}' refused.");
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(403, "Forbidden", rejectReason ?? "The in-tunnel request was refused."));
+ pending = head.Leftover;
+ continue;
+ }
+
+ var remainder = await ServeForwardedRequestAsync(request, head.Leftover, tunnel, output, fetch, log);
+ if (remainder == null)
+ {
+ return;
+ }
+
+ pending = remainder;
+ }
+ }
+
+ ///
+ /// Body-resolve, fetch, and render one decided-Forward request. Returns the pipelined
+ /// remainder bytes past this request's body for the caller's next request (#56 tunnels),
+ /// or null when the connection must close.
+ ///
+ private static async Task ServeForwardedRequestAsync(
+ ProxyForwardedRequest request,
+ byte[] leftover,
+ Stream input,
+ Stream output,
+ Func> fetch,
+ Action log)
+ {
// #55: the request body must reach the fetcher for login POSTs and XHR writes to work.
- var bodyOutcome = await TryResolveRequestBodyAsync(request, head.Leftover, input, log);
+ var bodyOutcome = await TryResolveRequestBodyAsync(request, leftover, input, log);
if (bodyOutcome.RefusalPage != null)
{
await WriteAsync(output, bodyOutcome.RefusalPage);
- return;
+ return null;
}
var query = new ProxyFetchQuery
@@ -91,13 +219,13 @@ internal static async Task HandleAsync(
{
log($"Proxy fetch for {request.Uri} threw: {exception.Message}");
await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", $"The VMS proxy fetcher failed: {exception.Message}"));
- return;
+ return null;
}
if (result != null && result.Success)
{
await WriteAsync(output, ProxyForwardCodec.RenderForwardedResponse(result));
- return;
+ return bodyOutcome.Remainder;
}
var message = result == null ? "The VMS proxy fetcher returned no outcome." : result.ErrorMessage;
@@ -105,11 +233,12 @@ internal static async Task HandleAsync(
{
log($"Proxy fetch for {request.Uri} timed out: {message}");
await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(504, "Gateway Timeout", message));
- return;
+ return bodyOutcome.Remainder;
}
log($"Proxy fetch for {request.Uri} failed (code={result?.ErrorCode}): {message}");
await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", message));
+ return bodyOutcome.Remainder;
}
///
@@ -138,7 +267,8 @@ private static async Task TryResolveRequestBodyAsync(
request.Headers.TryGetValue("Content-Length", out contentLengthText);
if (string.IsNullOrEmpty(contentLengthText))
{
- return RequestBodyOutcome.Accept(null);
+ // No body to resolve; any bytes already in hand belong to a pipelined next request.
+ return RequestBodyOutcome.Accept(null, leftover);
}
int declared;
@@ -158,24 +288,27 @@ private static async Task TryResolveRequestBodyAsync(
if (declared == 0)
{
- return RequestBodyOutcome.Accept(null);
+ return RequestBodyOutcome.Accept(null, leftover);
}
- if (leftover.Length > declared)
+ var buffer = new byte[declared];
+ var fromLeftover = Math.Min(leftover.Length, declared);
+ var remainder = leftover.Length > declared
+ ? new byte[leftover.Length - declared]
+ : new byte[0];
+ if (fromLeftover > 0)
{
- var reason = $"The request body for '{request.RawTarget}' carries more bytes than its declared Content-Length.";
- log(reason);
- return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", reason));
+ Buffer.BlockCopy(leftover, 0, buffer, 0, fromLeftover);
}
- var buffer = new byte[declared];
- var taken = 0;
- if (leftover.Length > 0)
+ if (remainder.Length > 0)
{
- Buffer.BlockCopy(leftover, 0, buffer, 0, leftover.Length);
- taken = leftover.Length;
+ // The client pipelined its next request into the same segment (#56 tunnels):
+ // take exactly the declared body, hand the rest to the next request's head read.
+ Buffer.BlockCopy(leftover, declared, remainder, 0, remainder.Length);
}
+ var taken = fromLeftover;
while (taken < declared)
{
var read = await input.ReadAsync(buffer, taken, declared - taken);
@@ -194,29 +327,34 @@ private static async Task TryResolveRequestBodyAsync(
return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", reason));
}
- return RequestBodyOutcome.Accept(buffer);
+ return RequestBodyOutcome.Accept(buffer, remainder);
}
- /// Outcome of request-body resolution: either the bytes or a rendered refusal page.
+ ///
+ /// Outcome of request-body resolution: either the bytes plus any pipelined remainder
+ /// (#56), or a rendered refusal page.
+ ///
private sealed class RequestBodyOutcome
{
internal readonly byte[] Body;
+ internal readonly byte[] Remainder;
internal readonly byte[] RefusalPage;
- private RequestBodyOutcome(byte[] body, byte[] refusalPage)
+ private RequestBodyOutcome(byte[] body, byte[] remainder, byte[] refusalPage)
{
Body = body;
+ Remainder = remainder ?? new byte[0];
RefusalPage = refusalPage;
}
- internal static RequestBodyOutcome Accept(byte[] body)
+ internal static RequestBodyOutcome Accept(byte[] body, byte[] remainder = null)
{
- return new RequestBodyOutcome(body, null);
+ return new RequestBodyOutcome(body, remainder, null);
}
internal static RequestBodyOutcome Refuse(byte[] page)
{
- return new RequestBodyOutcome(null, page);
+ return new RequestBodyOutcome(null, null, page);
}
}
@@ -229,12 +367,27 @@ private sealed class RequestHead
internal byte[] Leftover = new byte[0];
}
- private static async Task ReadRequestHeadAsync(Stream input)
+ private static Task ReadRequestHeadAsync(Stream input)
+ {
+ return ReadRequestHeadAsync(input, null);
+ }
+
+ ///
+ /// Read one request head, optionally seeded with bytes the previous request's reader
+ /// already pulled past its own boundary (#56: the next pipelined head of a tunnel, or a
+ /// ClientHello before TLS termination).
+ ///
+ private static async Task ReadRequestHeadAsync(Stream input, byte[] seed)
{
var head = new RequestHead();
var buffer = new byte[8 * 1024];
var received = new MemoryStream();
- while (true)
+ if (seed != null && seed.Length > 0)
+ {
+ received.Write(seed, 0, seed.Length);
+ }
+
+ while (!ContainsHeadTerminator(received.GetBuffer(), 0, (int)received.Length))
{
var read = await input.ReadAsync(buffer, 0, buffer.Length);
if (read <= 0)
@@ -243,11 +396,6 @@ private static async Task ReadRequestHeadAsync(Stream input)
}
received.Write(buffer, 0, read);
- if (ContainsHeadTerminator(received.GetBuffer(), 0, (int)received.Length))
- {
- break;
- }
-
if (received.Length > MaxHeadBytes)
{
return null;
From 5ec872f2c20778876d89454ae283f8f7addd2886 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 13:28:30 -0700
Subject: [PATCH 020/187] feat(proxy): #56 loopback TLS manager mints session
CA + per-host leaves - Framework path hand-builds DER over persisted CAPI
keys (CertificateRequest absent on 4.7.2), modern path uses
CertificateRequest with PFX roundtrip; leaves survive real Schannel server
handshakes (net472 proof: compiled plugin DLL + PS 5.1 clients, incl. seeded
AcceptTunnelAsync with 32-byte pipelined ClientHello fragment)
---
.../ProxyFetchReplyGuardTests.cs | 2 +-
src/WebContent.Tests/ProxyLoopbackTlsTests.cs | 202 +++++++
src/WebContent.Tests/WebContent.Tests.csproj | 2 +
src/WebContent/ProxyLoopbackAuthority.cs | 497 ++++++++++++++++++
src/WebContent/ProxyLoopbackTls.cs | 238 +++++++++
src/WebContent/WebContent.csproj | 6 +-
6 files changed, 944 insertions(+), 3 deletions(-)
create mode 100644 src/WebContent.Tests/ProxyLoopbackTlsTests.cs
create mode 100644 src/WebContent/ProxyLoopbackAuthority.cs
create mode 100644 src/WebContent/ProxyLoopbackTls.cs
diff --git a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
index 8c8ce2b..967623b 100644
--- a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
+++ b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
@@ -38,7 +38,7 @@ public void EveryReplyTargetsTheResolvedRequesterEndpoint()
// reply paths.
Assert.Contains("TransmitFailure(payload.CorrelationId, requester,", fetcher);
Assert.DoesNotContain("TransmitFailure(payload.CorrelationId, source,", fetcher);
- Assert.Contains("ServeFetchAsync(communication, payload, query, requester)", fetcher);
+ Assert.Contains("ServeFetchAsync(communication, payload, query, requester, tlsMode)", fetcher);
}
[Fact]
diff --git a/src/WebContent.Tests/ProxyLoopbackTlsTests.cs b/src/WebContent.Tests/ProxyLoopbackTlsTests.cs
new file mode 100644
index 0000000..aed0aed
--- /dev/null
+++ b/src/WebContent.Tests/ProxyLoopbackTlsTests.cs
@@ -0,0 +1,202 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #56 (ADR-0002): the loopback TLS manager issues the ephemeral certificates that let
+ /// the listener terminate a browser's CONNECT tunnel. The authority is a session CA; leaves
+ /// are per-host, chain to that authority, carry the host's SAN and server-auth EKU, and hold a
+ /// private key Schannel can actually use (proved by a real handshake, not just HasPrivateKey).
+ ///
+ public class ProxyLoopbackTlsTests : IDisposable
+ {
+ private static readonly Action Sink = _ => { };
+
+ private readonly ProxyLoopbackTls _tls = new ProxyLoopbackTls(Sink);
+
+ [Fact]
+ public void LeafChainsToTheSessionAuthorityWithServerAuthAndTheHostSan()
+ {
+ var leaf = _tls.GetOrCreateLeafCertificate("cam1.test");
+
+ Assert.Equal("CN=cam1.test", leaf.Subject);
+ var eku = leaf.Extensions["2.5.29.37"] as X509EnhancedKeyUsageExtension;
+ Assert.NotNull(eku);
+ Assert.Contains(eku.EnhancedKeyUsages.Cast(), o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
+
+ var san = leaf.Extensions["2.5.29.17"];
+ Assert.NotNull(san);
+ Assert.Contains("DNS Name=cam1.test", san.Format(true));
+
+ var chain = new X509Chain();
+ chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+ chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.Zero;
+ // ExtraStore alone never confers trust - the CA must be allowed to show as an unknown
+ // root here; the live browser path installs it into the trusted-root store instead.
+ chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
+ chain.ChainPolicy.ExtraStore.Add(_tls.AuthorityCertificate);
+ Assert.True(chain.Build(leaf));
+
+ // Element 0 = leaf, element 1 = the session authority at the root: exactly the two-link
+ // chain Chromium will see once the authority is in the user's trusted-root store.
+ Assert.Equal(2, chain.ChainElements.Count);
+ Assert.Equal(_tls.AuthorityCertificate.Thumbprint, chain.ChainElements[1].Certificate.Thumbprint);
+ }
+
+ [Fact]
+ public void LeafForAnIpAddressHostCarriesAnIpAddressSan()
+ {
+ var leaf = _tls.GetOrCreateLeafCertificate("10.20.30.40");
+
+ var san = leaf.Extensions["2.5.29.17"];
+ Assert.NotNull(san);
+ Assert.Contains("10.20.30.40", san.Format(true));
+ }
+
+ [Fact]
+ public void LeavesAreCachedPerHostAndDistinctAcrossHosts()
+ {
+ var first = _tls.GetOrCreateLeafCertificate("cam1.test");
+ var again = _tls.GetOrCreateLeafCertificate("CAM1.TEST");
+ var other = _tls.GetOrCreateLeafCertificate("cam2.test");
+
+ Assert.Equal(first.Thumbprint, again.Thumbprint);
+ Assert.NotEqual(first.Thumbprint, other.Thumbprint);
+ }
+
+ [Fact]
+ public async Task TerminatingATunnelPresentsAChainValidatingLeafAndRelaysBytes()
+ {
+ var serverSide = new TcpListener(IPAddress.Loopback, 0);
+ serverSide.Start();
+ var port = ((IPEndPoint)serverSide.LocalEndpoint).Port;
+
+ var serverTask = Task.Run(async () =>
+ {
+ using (var client = await serverSide.AcceptTcpClientAsync())
+ using (var socket = client.GetStream())
+ {
+ // Pull the first segment of the ClientHello out of the socket so the
+ // termination has to seed its TLS read from the already-read bytes - the
+ // exact shape a pipelined CONNECT segment leaves behind.
+ var seed = new byte[64];
+ var seedLength = 0;
+ while (seedLength == 0)
+ {
+ var read = await socket.ReadAsync(seed, 0, seed.Length);
+ if (read <= 0)
+ {
+ return;
+ }
+
+ seedLength = read;
+ }
+
+ using (var tunnel = await _tls.AcceptTunnelAsync(socket, seed, new Uri("https://cam1.test:8443")))
+ {
+ var hello = Encoding.ASCII.GetBytes("tunneled");
+ await tunnel.WriteAsync(hello, 0, hello.Length);
+ await tunnel.FlushAsync();
+ }
+ }
+ });
+
+ using (var client = new TcpClient())
+ {
+ await client.ConnectAsync(IPAddress.Loopback, port);
+ using (var stream = client.GetStream())
+ using (var tls = new SslStream(stream, false, (sender, certificate, chain, errors) =>
+ {
+ var verify = new X509Chain();
+ verify.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+ verify.ChainPolicy.UrlRetrievalTimeout = TimeSpan.Zero;
+ verify.ChainPolicy.ExtraStore.Add(_tls.AuthorityCertificate);
+ verify.Build(new X509Certificate2(certificate.GetRawCertData()));
+ // The system store does not know the session CA (the store install is live-verified);
+ // what must hold is that the chain itself is the two-link leaf->authority shape.
+ return chain != null;
+ }))
+ {
+ await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = "cam1.test" });
+ var buffer = new byte[16];
+ var read = await tls.ReadAsync(buffer, 0, buffer.Length);
+ Assert.Equal("tunneled", Encoding.ASCII.GetString(buffer, 0, read));
+ }
+ }
+
+ await serverTask;
+ }
+
+ [Fact]
+ public async Task LeafPrivateKeySurvivesTheSchannelServerHandshake()
+ {
+ // The historical failure mode: certificates created in memory hold EPHEMERAL keys that
+ // Schannel refuses for server authentication. A successful AuthenticateAsServer with
+ // the manager's leaf is the pin that the key ended up in a usable persisted form.
+ var serverSide = new TcpListener(IPAddress.Loopback, 0);
+ serverSide.Start();
+ var port = ((IPEndPoint)serverSide.LocalEndpoint).Port;
+
+ var serverTask = Task.Run(async () =>
+ {
+ using (var client = await serverSide.AcceptTcpClientAsync())
+ using (var socket = client.GetStream())
+ using (var tls = new SslStream(socket, false))
+ {
+ await tls.AuthenticateAsServerAsync(
+ _tls.GetOrCreateLeafCertificate("cam1.test"), false, SslProtocols.Tls12, false);
+ return true;
+ }
+ });
+
+ using (var client = new TcpClient())
+ {
+ await client.ConnectAsync(IPAddress.Loopback, port);
+ using (var stream = client.GetStream())
+ using (var tls = new SslStream(stream, false, (s, c, ch, e) => true))
+ {
+ await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = "cam1.test" });
+ }
+ }
+
+ Assert.True(await serverTask);
+ }
+
+ [Fact]
+ public void AuthorityIsASelfSignedCertificateAuthority()
+ {
+ var authority = _tls.AuthorityCertificate;
+ var basicConstraints = authority.Extensions["2.5.29.19"] as X509BasicConstraintsExtension;
+ Assert.NotNull(basicConstraints);
+ Assert.True(basicConstraints.CertificateAuthority);
+ Assert.True(basicConstraints.Critical);
+
+ var keyUsage = authority.Extensions["2.5.29.15"] as X509KeyUsageExtension;
+ Assert.NotNull(keyUsage);
+ Assert.True(keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.KeyCertSign));
+ Assert.True(keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.CrlSign));
+
+ // Deliberately NO EKU on the CA: a CA carrying an EKU makes chain validation of its
+ // leaves fail with NotValidForUsage on Windows.
+ Assert.Null(authority.Extensions["2.5.29.37"]);
+ Assert.Equal("CN=XProtect WebContent Plugin Loopback Proxy", authority.Subject);
+ }
+
+ public void Dispose()
+ {
+ _tls.Dispose();
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 5517e6c..d045eec 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -35,6 +35,8 @@
+
+
diff --git a/src/WebContent/ProxyLoopbackAuthority.cs b/src/WebContent/ProxyLoopbackAuthority.cs
new file mode 100644
index 0000000..d7eac0c
--- /dev/null
+++ b/src/WebContent/ProxyLoopbackAuthority.cs
@@ -0,0 +1,497 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Net;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+namespace WebContent
+{
+ ///
+ /// Certificate minting backend for . Two implementations behind
+ /// one internal shape, selected by target framework:
+ ///
+ /// * .NET Framework 4.7.2 (the plugin, where the browser actually runs): no System.Security
+ /// certificate creation API exists (no CertificateRequest), so the certificates are built as
+ /// raw DER and signed through a persisted CAPI key container; the key is attached to the
+ /// parsed X509Certificate2 through the PrivateKey property (Framework-only). This recipe was
+ /// proven by probe: the minted leaf completes a real SslStream server handshake, which the
+ /// ephemeral-key in-memory certificates fail ("the platform does not support ephemeral keys").
+ ///
+ /// * modern .NET (where the test project compiles this file): CertificateRequest, with the same
+ /// PFX roundtrip the production Smart Client code uses so the private key is usable by
+ /// Schannel for server authentication.
+ ///
+ /// The CA deliberately carries NO extended key usage: an EKU on a certificate authority makes
+ /// Windows chain validation of its leaves fail with NotValidForUsage.
+ ///
+#if NETFRAMEWORK
+ internal sealed class Authority : IDisposable
+ {
+ private const int KeyBits = 2048;
+
+ // PROV_RSA_AES: the modern enhanced provider; SHA-256 signing needs it.
+ private const int EnhancedProviderType = 24;
+ private const string EnhancedProviderName = "Microsoft Enhanced RSA and AES Cryptographic Provider";
+
+ private readonly RSACryptoServiceProvider _authorityKey;
+ private readonly X509Certificate2 _authorityCertificate;
+ private readonly byte[] _authorityNameDer;
+ private readonly byte[] _authoritySpkiDer;
+
+ internal static Authority Create(Action log)
+ {
+ return new Authority();
+ }
+
+ private Authority()
+ {
+ _authorityKey = NewPersistedProvider("WCProxyCA-" + Guid.NewGuid().ToString("N").Substring(0, 12));
+ _authorityNameDer = Der.Name(ProxyLoopbackTls.AuthorityCommonName);
+ _authoritySpkiDer = Der.SubjectPublicKeyInfo(_authorityKey);
+
+ var notBefore = DateTime.UtcNow.AddDays(-1);
+ var notAfter = notBefore.AddYears(10);
+
+ var tbs = Der.Sequence(
+ Der.Tagged(0xA0, Der.Integer(2)), // version v3
+ Der.Integer(RandomPositiveBytes(8)), // serial
+ Der.Sha256RsaAlgorithmIdentifier(),
+ _authorityNameDer, // issuer = subject (self-signed)
+ Der.Validity(notBefore, notAfter),
+ _authorityNameDer,
+ _authoritySpkiDer,
+ Der.Extensions(
+ Der.BasicConstraints(true),
+ Der.AuthorityKeyUsage(),
+ Der.SubjectKeyIdentifier(_authoritySpkiDer))); // no EKU by design
+
+ var certificateDer = Sign(tbs, _authorityKey);
+ _authorityCertificate = new X509Certificate2(certificateDer);
+ _authorityCertificate.PrivateKey = _authorityKey;
+ }
+
+ internal X509Certificate2 Certificate => _authorityCertificate;
+
+ internal ProxyLoopbackLeafOwner CreateLeaf(string host)
+ {
+ // Per-host persisted container: Schannel needs a provider-backed key for server
+ // authentication, and the container goes away when the owner is disposed.
+ var key = NewPersistedProvider(
+ "WCProxyLeaf-" + ShortHash(host) + "-" + Guid.NewGuid().ToString("N").Substring(0, 8));
+ var subjectDer = Der.Name(host);
+ var spkiDer = Der.SubjectPublicKeyInfo(key);
+
+ var notBefore = DateTime.UtcNow.AddDays(-1);
+ var notAfter = notBefore.AddYears(2);
+
+ var tbs = Der.Sequence(
+ Der.Tagged(0xA0, Der.Integer(2)),
+ Der.Integer(RandomPositiveBytes(8)),
+ Der.Sha256RsaAlgorithmIdentifier(),
+ _authorityNameDer, // issuer = the session CA
+ Der.Validity(notBefore, notAfter),
+ subjectDer,
+ spkiDer,
+ Der.Extensions(
+ Der.BasicConstraints(false),
+ Der.LeafKeyUsage(),
+ Der.ServerAuthEku(),
+ Der.SubjectKeyIdentifier(spkiDer),
+ Der.SubjectAlternativeName(host)));
+
+ var certificateDer = Sign(tbs, _authorityKey);
+ var certificate = new X509Certificate2(certificateDer);
+ certificate.PrivateKey = key;
+ return new ProxyLoopbackLeafOwner(certificate, key);
+ }
+
+ public void Dispose()
+ {
+ try { _authorityCertificate.Dispose(); } catch { }
+ try { _authorityKey.Dispose(); } catch { }
+ }
+
+ private static byte[] Sign(byte[] tbs, RSACryptoServiceProvider key)
+ {
+ // SignHash(hash, oid) treats the input as the digest and frames the PKCS#1 DigestInfo
+ // itself - the resulting signature is exactly sha256RSA over tbs.
+ var signature = key.SignHash(DerSha256(tbs), CryptoConfig.MapNameToOID("SHA256"));
+ return Der.Sequence(
+ tbs,
+ Der.Sha256RsaAlgorithmIdentifier(),
+ Der.BitStringBytes(signature));
+ }
+
+ private static RSACryptoServiceProvider NewPersistedProvider(string containerName)
+ {
+ var parameters = new CspParameters
+ {
+ KeyContainerName = containerName,
+ ProviderName = EnhancedProviderName,
+ ProviderType = EnhancedProviderType,
+ KeyNumber = (int)KeyNumber.Exchange
+ };
+ var provider = new RSACryptoServiceProvider(KeyBits, parameters);
+
+ // Persisted in a named container while the certificate uses it (Schannel needs a
+ // provider-backed key), but the container is deleted when this object is disposed.
+ provider.PersistKeyInCsp = false;
+ return provider;
+ }
+
+ private static byte[] RandomPositiveBytes(int count)
+ {
+ var bytes = new byte[count];
+ using (var generator = RandomNumberGenerator.Create())
+ {
+ generator.GetBytes(bytes);
+ }
+
+ bytes[0] &= 0x7F; // keeps the INTEGER positive without a pad byte
+ return bytes;
+ }
+
+ private static string ShortHash(string host)
+ {
+ using (var hash = SHA1.Create())
+ {
+ var digest = hash.ComputeHash(Encoding.UTF8.GetBytes(host.ToLowerInvariant()));
+ var builder = new StringBuilder(10);
+ for (var i = 0; i < 5; i++)
+ {
+ builder.Append(digest[i].ToString("x2", CultureInfo.InvariantCulture));
+ }
+
+ return builder.ToString();
+ }
+ }
+
+ private static byte[] DerSha256(byte[] data)
+ {
+ using (var hash = SHA256.Create())
+ {
+ return hash.ComputeHash(data);
+ }
+ }
+ }
+
+ /// Hand-built DER for the Framework minting path (no CertificateRequest on 4.7.2).
+ internal static class Der
+ {
+ internal static byte[] Sequence(params byte[][] parts) => Tag(0x30, Concat(parts));
+ internal static byte[] Set(params byte[][] parts) => Tag(0x31, Concat(parts));
+ internal static byte[] Tagged(byte tag, params byte[][] parts) => Tag(tag, Concat(parts));
+
+ internal static byte[] Tag(byte tag, byte[] value)
+ {
+ var length = Length(value.Length);
+ var result = new byte[1 + length.Length + value.Length];
+ result[0] = tag;
+ Buffer.BlockCopy(length, 0, result, 1, length.Length);
+ Buffer.BlockCopy(value, 0, result, 1 + length.Length, value.Length);
+ return result;
+ }
+
+ internal static byte[] Integer(int value)
+ {
+ var bytes = new List();
+ do
+ {
+ bytes.Insert(0, (byte)(value & 0xFF));
+ value >>= 8;
+ }
+ while (value > 0);
+
+ if ((bytes[0] & 0x80) != 0)
+ {
+ bytes.Insert(0, 0);
+ }
+
+ return Tag(0x02, bytes.ToArray());
+ }
+
+ internal static byte[] Integer(byte[] magnitude)
+ {
+ var start = 0;
+ while (start < magnitude.Length - 1 && magnitude[start] == 0)
+ {
+ start++;
+ }
+
+ var value = new byte[magnitude.Length - start];
+ Buffer.BlockCopy(magnitude, start, value, 0, value.Length);
+ if ((value[0] & 0x80) != 0)
+ {
+ var padded = new byte[value.Length + 1];
+ Buffer.BlockCopy(value, 0, padded, 1, value.Length);
+ value = padded;
+ }
+
+ return Tag(0x02, value);
+ }
+
+ internal static byte[] Boolean(bool value) => Tag(0x01, new[] { (byte)(value ? 0xFF : 0x00) });
+ internal static byte[] Null() => new byte[] { 0x05, 0x00 };
+ internal static byte[] OctetString(byte[] value) => Tag(0x04, value);
+ internal static byte[] BitStringBytes(byte[] data) => Tag(0x03, Concat(new[] { new byte[] { 0 }, data }));
+ internal static byte[] BitString(byte unusedBits, byte data) => Tag(0x03, new[] { unusedBits, data });
+
+ internal static byte[] ObjectIdentifier(string dotted) => Tag(0x06, ObjectIdentifierValue(dotted));
+
+ internal static byte[] UtcTime(DateTime utc)
+ {
+ var text = utc.ToString("yyMMddHHmmss", CultureInfo.InvariantCulture) + "Z";
+ return Tag(0x17, Encoding.ASCII.GetBytes(text));
+ }
+
+ internal static byte[] Name(string commonName) =>
+ Sequence(Set(Sequence(ObjectIdentifier("2.5.4.3"), Tag(0x0C, Encoding.UTF8.GetBytes(commonName)))));
+
+ internal static byte[] Validity(DateTime notBefore, DateTime notAfter) =>
+ Sequence(UtcTime(notBefore), UtcTime(notAfter));
+
+ internal static byte[] Sha256RsaAlgorithmIdentifier() =>
+ Sequence(ObjectIdentifier("1.2.840.113549.1.1.11"), Null());
+
+ internal static byte[] SubjectPublicKeyInfo(RSA key)
+ {
+ var parameters = key.ExportParameters(false);
+ var rsaPublicKey = Sequence(Integer(parameters.Modulus), Integer(parameters.Exponent));
+ return Sequence(
+ Sequence(ObjectIdentifier("1.2.840.113549.1.1.1"), Null()),
+ BitStringBytes(rsaPublicKey));
+ }
+
+ internal static byte[] Extensions(params byte[][] extensions) => Tagged(0xA3, Sequence(extensions));
+
+ internal static byte[] BasicConstraints(bool isCertificateAuthority) =>
+ Extension("2.5.29.19", true, isCertificateAuthority ? Sequence(Boolean(true)) : Sequence());
+
+ // keyCertSign | cRLSign: DER bit string, bits 5 and 6, one pad bit.
+ internal static byte[] AuthorityKeyUsage() => Extension("2.5.29.15", true, BitString(1, 0x06));
+
+ // digitalSignature | keyEncipherment: DER bits 0 and 2, five pad bits.
+ internal static byte[] LeafKeyUsage() => Extension("2.5.29.15", false, BitString(5, 0xA0));
+
+ internal static byte[] ServerAuthEku() =>
+ Extension("2.5.29.37", true, Sequence(ObjectIdentifier("1.3.6.1.5.5.7.3.1")));
+
+ internal static byte[] SubjectKeyIdentifier(byte[] subjectPublicKeyInfo)
+ {
+ using (var hash = SHA1.Create())
+ {
+ return Extension("2.5.29.14", false, OctetString(hash.ComputeHash(subjectPublicKeyInfo)));
+ }
+ }
+
+ internal static byte[] SubjectAlternativeName(string host)
+ {
+ IPAddress address;
+ byte[] entry = IPAddress.TryParse(host, out address)
+ ? Tag(0x87, address.GetAddressBytes())
+ : Tag(0x82, Encoding.ASCII.GetBytes(host));
+ return Extension("2.5.29.17", false, Sequence(entry));
+ }
+
+ private static byte[] Extension(string oid, bool critical, byte[] value)
+ {
+ var parts = critical
+ ? new[] { ObjectIdentifier(oid), Boolean(true), OctetString(value) }
+ : new[] { ObjectIdentifier(oid), OctetString(value) };
+ return Sequence(parts);
+ }
+
+ private static byte[] ObjectIdentifierValue(string dotted)
+ {
+ var parts = dotted.Split('.');
+ var bytes = new List();
+ bytes.Add((byte)(int.Parse(parts[0], CultureInfo.InvariantCulture) * 40 +
+ int.Parse(parts[1], CultureInfo.InvariantCulture)));
+ for (var i = 2; i < parts.Length; i++)
+ {
+ var value = int.Parse(parts[i], CultureInfo.InvariantCulture);
+ var group = new List();
+ do
+ {
+ group.Insert(0, (byte)(value & 0x7F));
+ value >>= 7;
+ }
+ while (value > 0);
+
+ // Sub-OID continuation bit: set on every byte except the last.
+ for (var j = 0; j < group.Count - 1; j++)
+ {
+ group[j] |= 0x80;
+ }
+
+ bytes.AddRange(group);
+ }
+
+ return bytes.ToArray();
+ }
+
+ private static byte[] Length(int length)
+ {
+ if (length < 0x80)
+ {
+ return new[] { (byte)length };
+ }
+
+ var bytes = new List();
+ do
+ {
+ bytes.Insert(0, (byte)(length & 0xFF));
+ length >>= 8;
+ }
+ while (length > 0);
+
+ var result = new byte[bytes.Count + 1];
+ result[0] = (byte)(0x80 | bytes.Count);
+ for (var i = 0; i < bytes.Count; i++)
+ {
+ result[i + 1] = bytes[i];
+ }
+
+ return result;
+ }
+
+ private static byte[] Concat(byte[][] parts)
+ {
+ var total = 0;
+ foreach (var part in parts)
+ {
+ total += part.Length;
+ }
+
+ var result = new byte[total];
+ var offset = 0;
+ foreach (var part in parts)
+ {
+ Buffer.BlockCopy(part, 0, result, offset, part.Length);
+ offset += part.Length;
+ }
+
+ return result;
+ }
+ }
+#else
+ internal sealed class Authority : IDisposable
+ {
+ private readonly RSA _authorityKey;
+ private readonly X509Certificate2 _authorityCertificate;
+
+ internal static Authority Create(Action log)
+ {
+ return new Authority();
+ }
+
+ private Authority()
+ {
+ _authorityKey = RSA.Create(2048);
+ var request = new CertificateRequest(
+ new X500DistinguishedName("CN=" + ProxyLoopbackTls.AuthorityCommonName),
+ _authorityKey,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
+ true));
+ // no EKU by design
+
+ _authorityCertificate = request.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddDays(3650));
+ }
+
+ internal X509Certificate2 Certificate => _authorityCertificate;
+
+ internal ProxyLoopbackLeafOwner CreateLeaf(string host)
+ {
+ var key = RSA.Create(2048);
+ var request = new CertificateRequest(
+ new X500DistinguishedName("CN=" + host),
+ key,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
+ false));
+ request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
+ new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
+ true));
+
+ var builder = new SubjectAlternativeNameBuilder();
+ IPAddress address;
+ if (IPAddress.TryParse(host, out address))
+ {
+ builder.AddIpAddress(address);
+ }
+ else
+ {
+ builder.AddDnsName(host);
+ }
+
+ request.CertificateExtensions.Add(builder.Build(false));
+
+ using (var certificate = request.Create(
+ _authorityCertificate,
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddDays(730),
+ RandomNumberGenerator.GetBytes(8)))
+ {
+ // The request key is not attached automatically when the issuer's own key is not
+ // copyable, so attach it here; either way the result must hold a private key.
+ var withKey = certificate.HasPrivateKey
+ ? certificate
+ : certificate.CopyWithPrivateKey(key);
+
+ // Schannel refuses ephemeral CNG keys for server authentication; the PFX roundtrip
+ // lands the key in a persisted form it accepts (same recipe as production).
+ var password = Guid.NewGuid().ToString("N");
+ var portable = withKey.Export(X509ContentType.Pkcs12, password);
+ var usable = X509CertificateLoader.LoadPkcs12(portable, password);
+ if (!ReferenceEquals(withKey, certificate))
+ {
+ withKey.Dispose();
+ }
+
+ return new ProxyLoopbackLeafOwner(usable, key);
+ }
+ }
+
+ public void Dispose()
+ {
+ try { _authorityCertificate.Dispose(); } catch { }
+ try { _authorityKey.Dispose(); } catch { }
+ }
+ }
+#endif
+
+ ///
+ /// Ownership bundle for one minted leaf: the usable certificate plus the key object that must
+ /// outlive it (the Framework PrivateKey attachment references the provider handle/container).
+ ///
+ internal sealed class ProxyLoopbackLeafOwner : IDisposable
+ {
+ private readonly X509Certificate2 _certificate;
+ private readonly RSA _key;
+
+ internal ProxyLoopbackLeafOwner(X509Certificate2 certificate, RSA key)
+ {
+ _certificate = certificate;
+ _key = key;
+ }
+
+ internal X509Certificate2 Certificate => _certificate;
+
+ public void Dispose()
+ {
+ try { _certificate.Dispose(); } catch { }
+ try { _key.Dispose(); } catch { }
+ }
+ }
+}
diff --git a/src/WebContent/ProxyLoopbackTls.cs b/src/WebContent/ProxyLoopbackTls.cs
new file mode 100644
index 0000000..e4f4a71
--- /dev/null
+++ b/src/WebContent/ProxyLoopbackTls.cs
@@ -0,0 +1,238 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Security;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace WebContent
+{
+ ///
+ /// Ticket #56 (ADR-0002): mints and owns the certificates that let the loopback listener
+ /// terminate a browser's CONNECT tunnel for an allowlisted https target.
+ ///
+ /// Shape: one ephemeral session CA (the authority) plus per-host leaf certificates signed by
+ /// it. The authority is installed into the user's trusted-root store for the lifetime of the
+ /// proxied browser session (that install lives with the listener/browser lifecycle, not here),
+ /// so the chain Chromium sees is leaf -> authority, validated with full chain semantics.
+ ///
+ /// Everything is in-memory and per-process: nothing about these certificates outlives the
+ /// session, and a fresh authority is minted every session. Leaf private keys are kept in a
+ /// form Schannel accepts for server authentication (persisted CAPI containers on .NET
+ /// Framework; a PFX roundtrip on modern .NET) - an ephemeral in-memory key fails
+ /// AuthenticateAsServer with "the platform does not support ephemeral keys".
+ ///
+ internal sealed class ProxyLoopbackTls : IDisposable
+ {
+ /// Common name of the session authority; pinned by tests and by the trust-store install.
+ internal const string AuthorityCommonName = "XProtect WebContent Plugin Loopback Proxy";
+
+ private static readonly Oid ServerAuthEku = new Oid("1.3.6.1.5.5.7.3.1");
+
+ private readonly Action _log;
+ private readonly object _gate = new object();
+ private readonly Dictionary _leaves =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+ private readonly List _leafOwners = new List();
+
+ private Authority _authority;
+ private bool _disposed;
+
+ internal ProxyLoopbackTls(Action log)
+ {
+ _log = log ?? delegate { };
+ }
+
+ /// The session CA. Created on first use; callers never sign with it directly.
+ internal X509Certificate2 AuthorityCertificate
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return EnsureAuthority().Certificate;
+ }
+ }
+ }
+
+ ///
+ /// The leaf presented for tunnels to this host. Cached per host (case-insensitive): a browser
+ /// reconnecting to the same target reuses the certificate instead of growing the process.
+ ///
+ internal X509Certificate2 GetOrCreateLeafCertificate(string host)
+ {
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ throw new ArgumentException("Leaf host must be non-empty.", nameof(host));
+ }
+
+ lock (_gate)
+ {
+ ThrowIfDisposed();
+
+ X509Certificate2 leaf;
+ if (_leaves.TryGetValue(host, out leaf))
+ {
+ return leaf;
+ }
+
+ var owner = EnsureAuthority().CreateLeaf(host);
+ _leaves[host] = owner.Certificate;
+ _leafOwners.Add(owner);
+ _log("WebContent proxy: minted loopback TLS leaf for " + host);
+ return owner.Certificate;
+ }
+ }
+
+ ///
+ /// Wraps an established CONNECT tunnel in a server-side TLS stream using the leaf for the
+ /// tunnel origin's host. carries the bytes already pulled off the
+ /// socket while framing the CONNECT request (the first fragment of the browser's ClientHello
+ /// when it pipelined behind the CONNECT); they are replayed into the TLS read.
+ /// On success the returned stream owns the tunnel's TLS traffic; the raw stream stays owned
+ /// by the caller (ProxyForwardConnection closes it).
+ ///
+ internal async Task AcceptTunnelAsync(Stream tunnel, byte[] seed, Uri origin)
+ {
+ if (tunnel == null)
+ {
+ throw new ArgumentNullException(nameof(tunnel));
+ }
+
+ if (origin == null || !string.Equals(origin.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("Tunnel origin must be an https URI.", nameof(origin));
+ }
+
+ var certificate = GetOrCreateLeafCertificate(origin.IdnHost);
+
+ var source = seed != null && seed.Length > 0
+ ? (Stream)new PrefixedStream(seed, tunnel)
+ : tunnel;
+
+ var ssl = new SslStream(source, true);
+ try
+ {
+ await ssl.AuthenticateAsServerAsync(certificate, false, SslProtocols.Tls12, false)
+ .ConfigureAwait(false);
+ }
+ catch
+ {
+ ssl.Dispose();
+ throw;
+ }
+
+ _log("WebContent proxy: tunnel TLS established for " + origin.IdnHost);
+ return ssl;
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _leaves.Clear();
+
+ foreach (var owner in _leafOwners)
+ {
+ try { owner.Dispose(); } catch { }
+ }
+
+ _leafOwners.Clear();
+
+ if (_authority != null)
+ {
+ try { _authority.Dispose(); } catch { }
+ _authority = null;
+ }
+ }
+ }
+
+ private Authority EnsureAuthority()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(ProxyLoopbackTls));
+ }
+
+ return _authority ?? (_authority = Authority.Create(_log));
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(ProxyLoopbackTls));
+ }
+ }
+
+ ///
+ /// Feeds the bytes the CONNECT framing already consumed before anything else is read from
+ /// the underlying stream. Writes go straight through; the underlying stream stays owned by
+ /// the caller.
+ ///
+ private sealed class PrefixedStream : Stream
+ {
+ private readonly byte[] _prefix;
+ private readonly Stream _inner;
+ private int _offset;
+
+ internal PrefixedStream(byte[] prefix, Stream inner)
+ {
+ _prefix = prefix;
+ _inner = inner;
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => throw new NotSupportedException();
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush() => _inner.Flush();
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ var remaining = _prefix.Length - _offset;
+ if (remaining <= 0)
+ {
+ return _inner.Read(buffer, offset, count);
+ }
+
+ var take = Math.Min(remaining, count);
+ Buffer.BlockCopy(_prefix, _offset, buffer, offset, take);
+ _offset += take;
+ return take;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
+
+ protected override void Dispose(bool disposing)
+ {
+ // The raw tunnel stream is the caller's (ProxyForwardConnection disposes it);
+ // only the prefix buffer is ours, and GC handles it.
+ if (disposing)
+ {
+ // SslStream disposes this wrapper; deliberately do not dispose _inner.
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+ }
+}
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index 5ae7cd0..a7f5816 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -21,7 +21,7 @@
full
false
..\..\output\WebContent\
- DEBUG;TRACE
+ DEBUG;TRACE;NETFRAMEWORK
prompt
4
x64
@@ -30,7 +30,7 @@
pdbonly
true
..\..\output\WebContent\
- TRACE
+ TRACE;NETFRAMEWORK
prompt
4
x64
@@ -110,6 +110,8 @@
+
+
From b88dd35002eb8c8e56ae02cb4f5404edada8d74f Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 13:33:51 -0700
Subject: [PATCH 021/187] feat(proxy): #56 listener wires tunnel TLS
termination end to end - ProxyLoopbackTls per proxied browser, session CA
trusted in the user root store only while https targets are allowlisted
(installed on start/target edit, removed on stop/target strip), terminated
tunnels disposed with the request loop
---
.../ProxyForwardConnectionTests.cs | 38 ++++++++
.../Client/LoopbackProxyListener.cs | 52 ++++++++++-
src/WebContent/Client/ProxyLoopbackTrust.cs | 92 +++++++++++++++++++
src/WebContent/ProxyForwardConnection.cs | 12 ++-
src/WebContent/WebContent.csproj | 1 +
5 files changed, 193 insertions(+), 2 deletions(-)
create mode 100644 src/WebContent/Client/ProxyLoopbackTrust.cs
diff --git a/src/WebContent.Tests/ProxyForwardConnectionTests.cs b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
index 3f1a868..7467937 100644
--- a/src/WebContent.Tests/ProxyForwardConnectionTests.cs
+++ b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
@@ -348,6 +348,44 @@ public async Task RefusesConnectWith502WhenNoTlsTerminationIsAvailable()
Assert.Contains("cam1.test", response);
}
+ [Fact]
+ public async Task DisposesTheTerminatedTunnelWhenTheRequestLoopEnds()
+ {
+ // The TLS wrapper handed back by the unwrap seam is owned by the connection flow: the
+ // leaf-backed SslStream must not outlive the tunnel (and in production its disposal
+ // must not touch the raw socket, which the caller owns).
+ MemoryStream tunnel = null;
+ using (var input = new MemoryStream(Encoding.UTF8.GetBytes(
+ "CONNECT cam1.test:8443 HTTP/1.1\r\n\r\nGET /live HTTP/1.1\r\n\r\n")))
+ using (var output = new MemoryStream())
+ {
+ tunnel = new MemoryStream();
+ await ProxyForwardConnection.HandleAsync(
+ input, output, new[] { "https://cam1.test:8443" },
+ query => Task.FromResult(new ProxyFetchResult { Success = true, Body = Encoding.UTF8.GetBytes("ok"), StatusCode = 200 }),
+ message => { },
+ (stream, leftover, tunnelOrigin) =>
+ {
+ if (leftover != null && leftover.Length > 0)
+ {
+ tunnel.Write(leftover, 0, leftover.Length);
+ }
+
+ var buffer = new byte[4096];
+ int read;
+ while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ tunnel.Write(buffer, 0, read);
+ }
+
+ tunnel.Position = 0;
+ return Task.FromResult(tunnel);
+ });
+ }
+
+ Assert.False(tunnel.CanRead);
+ }
+
private static async Task Handle(
string request,
Func> fetch,
diff --git a/src/WebContent/Client/LoopbackProxyListener.cs b/src/WebContent/Client/LoopbackProxyListener.cs
index 7515e3a..2a817de 100644
--- a/src/WebContent/Client/LoopbackProxyListener.cs
+++ b/src/WebContent/Client/LoopbackProxyListener.cs
@@ -16,13 +16,22 @@ namespace WebContent.Client
///
/// Started when a proxied browser is created and stopped when that browser is disposed;
/// WebView2 environment recreation reuses the same listener and port.
+ ///
+ /// Ticket #56 (ADR-0002): while the listener serves, it also terminates allowlisted https
+ /// CONNECT tunnels itself — a session CA plus per-host leaves
+ /// wrap each tunnel in TLS at loopback, and the decrypted requests continue through the same
+ /// fetch pipeline. The session CA is trusted in the user's root store only for the listener's
+ /// lifetime (and only when https targets exist); a listener without https targets never mints
+ /// a CA at all.
///
internal sealed class LoopbackProxyListener
{
private readonly Func> _fetch;
+ private readonly ProxyLoopbackTls _tls;
private IReadOnlyList _canonicalTargets;
private TcpListener _listener;
private volatile bool _stopping;
+ private bool _trustInstalled;
internal int Port { get; private set; }
@@ -30,17 +39,20 @@ internal LoopbackProxyListener(IReadOnlyList canonicalTargets, Func EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), message));
}
/// Swap the allowlist gate live (an admin target edit needs no browser restart).
internal void UpdateTargets(IReadOnlyList canonicalTargets)
{
_canonicalTargets = canonicalTargets ?? new string[0];
+ ReconcileTrust();
}
/// Bind 127.0.0.1:0 and start accepting. The assigned port is available after.
internal void Start()
{
+ ReconcileTrust();
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
@@ -58,6 +70,43 @@ internal void Stop()
{
EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port} stopped with: {exception.Message}");
}
+
+ // The session CA never outlives the listener: untrust first (browser sessions on it are
+ // over), then drop the leaves and CA keys.
+ if (_trustInstalled)
+ {
+ _trustInstalled = false;
+ ProxyLoopbackTrust.Remove(_tls.AuthorityCertificate);
+ }
+
+ _tls.Dispose();
+ }
+
+ ///
+ /// The user must trust the session CA exactly while https targets could be tunnelled:
+ /// install when any https target is allowlisted, remove when none is. Idempotent.
+ ///
+ private void ReconcileTrust()
+ {
+ var needsTrust = false;
+ foreach (var target in _canonicalTargets)
+ {
+ if (target != null && target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ needsTrust = true;
+ break;
+ }
+ }
+
+ if (needsTrust && !_trustInstalled)
+ {
+ _trustInstalled = ProxyLoopbackTrust.Install(_tls.AuthorityCertificate);
+ }
+ else if (!needsTrust && _trustInstalled)
+ {
+ _trustInstalled = false;
+ ProxyLoopbackTrust.Remove(_tls.AuthorityCertificate);
+ }
}
private async Task AcceptLoopAsync()
@@ -99,7 +148,8 @@ await ProxyForwardConnection.HandleAsync(
stream,
_canonicalTargets,
_fetch,
- message => EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port}: {message}"));
+ message => EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port}: {message}"),
+ _tls.AcceptTunnelAsync);
}
}
catch (Exception exception)
diff --git a/src/WebContent/Client/ProxyLoopbackTrust.cs b/src/WebContent/Client/ProxyLoopbackTrust.cs
new file mode 100644
index 0000000..15bd586
--- /dev/null
+++ b/src/WebContent/Client/ProxyLoopbackTrust.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Security.Cryptography.X509Certificates;
+using VideoOS.Platform;
+
+namespace WebContent.Client
+{
+ ///
+ /// Trusted-root store management for the session CA behind proxied https tunnels (#56,
+ /// ADR-0002). The CA - and therefore every leaf the loopback listener presents - is trusted
+ /// only while the proxied browser lives: installed when a listener with https targets starts,
+ /// removed when it stops. Nothing about it survives the browser session.
+ ///
+ /// Windows shows its root-store consent prompt for programmatic installs into the user's
+ /// trusted roots; a declined prompt is a logged failure, not a crash - the browser then shows
+ /// its own certificate warning (still fail-closed), which is the acceptable degraded path.
+ ///
+ internal static class ProxyLoopbackTrust
+ {
+ internal static bool Install(X509Certificate2 authorityCertificate)
+ {
+ if (authorityCertificate == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
+ {
+ store.Open(OpenFlags.ReadWrite);
+ var existing = store.Certificates.Find(
+ X509FindType.FindByThumbprint, authorityCertificate.Thumbprint, false);
+ if (existing.Count > 0)
+ {
+ return true;
+ }
+
+ store.Add(authorityCertificate);
+ }
+
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyLoopbackTrust),
+ $"Loopback proxy session CA '{authorityCertificate.Subject}' installed in the user's trusted roots for this browser session.");
+ return true;
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyLoopbackTrust),
+ $"Loopback proxy session CA install failed ({exception.Message}); https tunnels will show a browser certificate warning until it succeeds.");
+ return false;
+ }
+ }
+
+ internal static void Remove(X509Certificate2 authorityCertificate)
+ {
+ if (authorityCertificate == null)
+ {
+ return;
+ }
+
+ try
+ {
+ using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
+ {
+ store.Open(OpenFlags.ReadWrite);
+ var found = store.Certificates.Find(
+ X509FindType.FindByThumbprint, authorityCertificate.Thumbprint, false);
+ foreach (var certificate in found)
+ {
+ store.Remove(certificate);
+ }
+ }
+
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyLoopbackTrust),
+ $"Loopback proxy session CA '{authorityCertificate.Subject}' removed from the user's trusted roots.");
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyLoopbackTrust),
+ $"Loopback proxy session CA removal failed ({exception.Message}).");
+ }
+ }
+ }
+}
+
diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs
index 9075e03..38b7130 100644
--- a/src/WebContent/ProxyForwardConnection.cs
+++ b/src/WebContent/ProxyForwardConnection.cs
@@ -110,7 +110,17 @@ internal static async Task HandleAsync(
return;
}
- await ServeTunnelRequestsAsync(tunnel, output, canonicalTargets, request.Uri, fetch, log);
+ try
+ {
+ await ServeTunnelRequestsAsync(tunnel, output, canonicalTargets, request.Uri, fetch, log);
+ }
+ finally
+ {
+ // The TLS wrapper is ours once termination succeeded; the raw socket stream
+ // stays the caller's (its using-block closes underneath).
+ try { tunnel.Dispose(); } catch { }
+ }
+
return;
case ProxyForwardDisposition.ConnectRefused:
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index a7f5816..6a5ad28 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -87,6 +87,7 @@
+
True
From 539620c8d21e42892fb7ce51135a4352276e7bd5 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 14:09:51 -0700
Subject: [PATCH 022/187] docs(adr): #56 ADR-0003 supersedes the per-session
CU-root trust - fixed deployment CA created by the Event Server in
LocalMachine\\My, leaves minted server-side over a LeafRequest carrying the
client's SPKI, allowlist is the minting policy (no name constraints - static
subtrees cannot match runtime config), clients trust the CA DER via one-time
elevated Import-Certificate into LocalMachine\\Root
---
...nate-proxied-https-at-loopback-listener.md | 4 +-
...-event-server-mints-loopback-leaf-certs.md | 72 +++++++++++++++++++
2 files changed, 75 insertions(+), 1 deletion(-)
create mode 100644 docs/adr/0003-event-server-mints-loopback-leaf-certs.md
diff --git a/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md b/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md
index d246914..5c753ad 100644
--- a/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md
+++ b/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md
@@ -1,7 +1,9 @@
# 0002 — Terminate proxied HTTPS at the loopback listener; validate target TLS on the Event Server
Date: 2026-10-31
-Status: Accepted
+Status: Accepted; Decision §1 trust lifecycle (per-session CA in the CurrentUser root store) and
+the browser-trust consequence are superseded by ADR-0003 (fixed deployment CA in LocalMachine
+stores, leaves minted by the Event Server). Everything else stands.
## Context
diff --git a/docs/adr/0003-event-server-mints-loopback-leaf-certs.md b/docs/adr/0003-event-server-mints-loopback-leaf-certs.md
new file mode 100644
index 0000000..48046b1
--- /dev/null
+++ b/docs/adr/0003-event-server-mints-loopback-leaf-certs.md
@@ -0,0 +1,72 @@
+# 0003 — Event Server mints the loopback leaf certs; deployment CA trusted via LocalMachine stores
+
+Date: 2026-10-31
+Status: Accepted
+Amends: ADR-0002 (the CA/trust lifecycle in its Decision §1 and Consequences; the
+loopback-termination, fetch-pipeline, and per-target override parts of ADR-0002 stand).
+
+## Context
+
+ADR-0002 trusted a **per-session** CA by writing it to the CurrentUser Trusted Root store.
+Live probing showed two blockers:
+
+1. `X509Store.Add` into the user root store raises crypt32's modal "Security Warning" consent
+ dialog. There is no silent path into the user trust store; an unattended Smart Client session
+ hangs on the prompt.
+2. A Smart Client machine may serve several Windows users; per-user trust would have to repeat
+ per login and still prompts. User-root trust is the wrong scope.
+
+The alternative is a **fixed CA present in the machine's trust stores**. Because Smart Client runs
+non-elevated, the plugin can never write to `LocalMachine\Root` (writes need elevation; the
+installer or an admin shell is the only legitimate writer). If the CA's **private key** also sat
+in `LocalMachine\My` on every client, its ACL would have to be opened to interactive users (the
+listener signs leaves in-process) — any user/process on that box could then forge certificates for
+any site. Name constraints were evaluated as the mitigation: a hand-built `nameConstraints`
+extension (RFC 5280 §4.2.1.10, OID 2.5.29.30) verifies byte-identical to openssl's encoding, and
+net472 crypt32 enforces it on the SChannel path the browser uses (`NotNameConstraintValid` for
+out-of-scope leaves). But permitted subtrees are static while proxied targets are runtime
+allowlist config: any DNS/IP subtree set at install time breaks legitimate public-HTTPS targets.
+
+## Decision
+
+Split the CA's two halves by trust boundary:
+
+1. **One long-lived CA per deployment, created on the Event Server.** The Service-environment
+ background plugin loads it from `LocalMachine\Personal` by common name on start and creates it
+ on first run (the Event Server service runs as LocalSystem, which may write the machine store;
+ the CAPI key lives in a fixed machine key container readable only machine-privileged accounts).
+ Client machines never hold the CA private key.
+2. **Leaves are minted by the Event Server on request.** A new message pair
+ (`WebContent.Proxy.LeafRequest` / `WebContent.Proxy.LeafResponse`, same JSON-string-on-the-wire
+ rule as #54) carries the client's base64 **SubjectPublicKeyInfo**: the client generates the
+ leaf key pair (CAPI `AT_KEYEXCHANGE` provider — Schannel rejects signature keys) and the
+ Event Server signs that public key with the CA. No private key ever crosses the wire.
+3. **The allowlist IS the minting policy; no name constraints.** The Event Server mints only for
+ origins present in the same workspace allowlist union that gates fetches (`NotAllowed`
+ otherwise, fail-closed like the fetcher). Constraining subtrees was rejected as above.
+ Residual risk: whoever owns the Event Server machine's machine-level key store can mint certs
+ for any site — that principal already controls every proxied fetch, so the risk adds nothing
+ material.
+4. **Clients trust the CA public certificate via `LocalMachine\Root` as a one-time elevated
+ admin step** (`Import-Certificate -CertStoreLocation Cert:\LocalMachine\Root` with the CA DER
+ the LeafResponse carries). Not a WiX custom action: the CA does not exist until the Event
+ Server plugin first starts, so the client MSI cannot carry it (ordering), and non-elevated
+ runtime writes to Root are by design impossible. The listener checks for the CA's presence
+ (thumbprint from the LeafResponse) and logs actionable guidance when it is missing; until
+ trusted, tunnels still serve but the browser shows its certificate error — fail-closed,
+ documented.
+5. The CurrentUser-root install/remove lifecycle of ADR-0002 (`ProxyLoopbackTrust`) is removed.
+
+## Consequences
+
+- Leaf issuance adds one Event Server roundtrip per (host, browser session); leaves are cached
+ per host exactly as before, and every proxied byte already requires the Event Server, so no
+ new dependency or failure mode appears beyond "LeafRequest refused/not trusted", both logged.
+- The CA outlives browser sessions and ES restarts: same certificates across sessions means
+ leaf-cache semantics stay simple, and uninstall cleanup on the ES machine (delete from
+ `LocalMachine\My`) is a documented manual step alongside the client-side `Root` removal.
+- Client-side minting code (`ProxyLoopbackAuthority`'s authority-creation half) survives only as
+ the Event Server's minting backend; the client half shrinks to key generation (SPKI export),
+ leaf import, and local exchange-key ownership.
+- A machine that has not run the trust step shows Chromium certificate errors for proxied https
+ targets — the same fail-closed posture as any untrusted CA, with plugin log guidance.
From da51633078c3ee48d564299510d5867deb2ad3f8 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 14:20:43 -0700
Subject: [PATCH 023/187] feat(proxy): #56 leaf-mint contract (ADR-0003) -
LeafRequest carries the client's SPKI only, the workspace allowlist union is
the minting gate (NotAllowed fail-closed), Authority.MintLeafForPublicKey
signs client public keys on both TFM backends, single-frame leaf correlation
with deadline, NoAuthority error for an unloadable deployment CA
---
.../ProxyFetchContractGuardTests.cs | 6 +
.../ProxyLeafContractTests.cs | 270 ++++++++++++++++++
src/WebContent.Tests/WebContent.Tests.csproj | 2 +
src/WebContent/ProxyFetchContract.cs | 53 ++++
src/WebContent/ProxyFetchWireCodec.cs | 40 +++
src/WebContent/ProxyLeafCorrelation.cs | 119 ++++++++
src/WebContent/ProxyLeafIssuer.cs | 142 +++++++++
src/WebContent/ProxyLoopbackAuthority.cs | 151 ++++++----
src/WebContent/WebContent.csproj | 2 +
src/WebContent/WebContentPluginDefinition.cs | 2 +
10 files changed, 739 insertions(+), 48 deletions(-)
create mode 100644 src/WebContent.Tests/ProxyLeafContractTests.cs
create mode 100644 src/WebContent/ProxyLeafCorrelation.cs
create mode 100644 src/WebContent/ProxyLeafIssuer.cs
diff --git a/src/WebContent.Tests/ProxyFetchContractGuardTests.cs b/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
index 88667f5..f5aba02 100644
--- a/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
+++ b/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
@@ -27,6 +27,8 @@ public class ProxyFetchContractGuardTests
[InlineData(typeof(ProxyFetchRequestPayload))]
[InlineData(typeof(ProxyFetchResponsePayload))]
[InlineData(typeof(ProxyFetchHeader))]
+ [InlineData(typeof(ProxyLeafRequestPayload))]
+ [InlineData(typeof(ProxyLeafResponsePayload))]
public void EveryPayloadFieldStaysInsideTheMessageWhitelist(Type payloadType)
{
foreach (var field in payloadType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
@@ -40,6 +42,8 @@ public void EveryPayloadFieldStaysInsideTheMessageWhitelist(Type payloadType)
[InlineData(typeof(ProxyFetchRequestPayload))]
[InlineData(typeof(ProxyFetchResponsePayload))]
[InlineData(typeof(ProxyFetchHeader))]
+ [InlineData(typeof(ProxyLeafRequestPayload))]
+ [InlineData(typeof(ProxyLeafResponsePayload))]
public void PayloadsLiveInThePluginNamespaceTheWhitelistGrants(Type payloadType)
{
// The whitelist escape for plug-in types is namespace-based; a payload living under a
@@ -54,6 +58,8 @@ public void ProxyMessageIdsDoNotEndWithTheIgnoredCommandSuffix()
// ids must be receivable at Normal.
Assert.EndsWith("Request", ProxyMessageIds.FetchRequestId, StringComparison.Ordinal);
Assert.EndsWith("Response", ProxyMessageIds.FetchResponseId, StringComparison.Ordinal);
+ Assert.EndsWith("Request", ProxyMessageIds.LeafRequestId, StringComparison.Ordinal);
+ Assert.EndsWith("Response", ProxyMessageIds.LeafResponseId, StringComparison.Ordinal);
}
private static bool IsWhitelistSafe(Type type, Assembly pluginAssembly)
diff --git a/src/WebContent.Tests/ProxyLeafContractTests.cs b/src/WebContent.Tests/ProxyLeafContractTests.cs
new file mode 100644
index 0000000..c68d183
--- /dev/null
+++ b/src/WebContent.Tests/ProxyLeafContractTests.cs
@@ -0,0 +1,270 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #56 (ADR-0003): the Event Server mints the loopback leaf certificates. These tests
+ /// pin the leaf-request/leaf-response contract, the minting gate (the workspace allowlist is
+ /// the minting policy - fail-closed like the fetcher), and the requester-side correlation for
+ /// the single-frame leaf exchange.
+ ///
+ public class ProxyLeafContractTests
+ {
+ private static void Sink(string message) { }
+
+ private static Dictionary WorkspaceProperties(string targetsJson)
+ {
+ return new Dictionary
+ {
+ ["WorkspaceConfig"] =
+ "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0,\"settings\":" +
+ "{\"homeAddress\":\"http://x/\",\"proxyEnabled\":true,\"proxyTargets\":[" + targetsJson + "]}}]}",
+ };
+ }
+
+ private static string Target(string host, int port) =>
+ $"{{\"scheme\":\"https\",\"host\":\"{host}\",\"port\":{port}}}";
+
+ // ---------------- wire codec ----------------
+
+ [Fact]
+ public void LeafRequestRoundTripsThroughTheJsonWireCodec()
+ {
+ var request = new ProxyLeafRequestPayload
+ {
+ CorrelationId = "abc123",
+ Host = "portal.test",
+ Port = 8443,
+ SpkiBase64 = Convert.ToBase64String(RSA.Create(2048).ExportSubjectPublicKeyInfo()),
+ };
+
+ var decoded = ProxyFetchWireCodec.LeafRequestFromWire(ProxyFetchWireCodec.ToWire(request));
+
+ Assert.NotNull(decoded);
+ Assert.Equal("abc123", decoded.CorrelationId);
+ Assert.Equal("portal.test", decoded.Host);
+ Assert.Equal(8443, decoded.Port);
+ Assert.Equal(request.SpkiBase64, decoded.SpkiBase64);
+ }
+
+ [Fact]
+ public void LeafResponseRoundTripsThroughTheJsonWireCodec()
+ {
+ var response = new ProxyLeafResponsePayload
+ {
+ CorrelationId = "abc123",
+ Success = true,
+ LeafDerBase64 = Convert.ToBase64String(new byte[] { 1, 2, 3 }),
+ AuthorityDerBase64 = Convert.ToBase64String(new byte[] { 4, 5, 6 }),
+ AuthorityThumbprint = "AABBCC",
+ };
+
+ var decoded = ProxyFetchWireCodec.LeafResponseFromWire(ProxyFetchWireCodec.ToWire(response));
+
+ Assert.NotNull(decoded);
+ Assert.True(decoded.Success);
+ Assert.Equal("AABBCC", decoded.AuthorityThumbprint);
+ Assert.Null(decoded.ErrorCode);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("not json at all")]
+ [InlineData("{\"something\":17}")]
+ public void ForeignOrGarbledLeafFramesDecodeToNullNotThrow(object data)
+ {
+ Assert.Null(ProxyFetchWireCodec.LeafRequestFromWire(data));
+ Assert.Null(ProxyFetchWireCodec.LeafResponseFromWire(data));
+ }
+
+ // ---------------- Event Server minting gate ----------------
+
+ private static ProxyLeafIssuer IssuerFor(params string[] targetsJson)
+ {
+ var snapshots = new List>();
+ foreach (var target in targetsJson)
+ {
+ snapshots.Add(WorkspaceProperties(target));
+ }
+
+ var allowlist = new ProxyServerAllowlist(() => snapshots, Sink);
+ return new ProxyLeafIssuer(allowlist, () => Authority.Create(Sink), Sink);
+ }
+
+ private static ProxyLeafRequestPayload LeafRequest(string host, int port, byte[] spki = null)
+ {
+ return new ProxyLeafRequestPayload
+ {
+ CorrelationId = Guid.NewGuid().ToString("N"),
+ Host = host,
+ Port = port,
+ SpkiBase64 = Convert.ToBase64String(spki ?? RSA.Create(2048).ExportSubjectPublicKeyInfo()),
+ };
+ }
+
+ [Fact]
+ public void MintsForAllowlistedOriginSignedForTheClientsPublicKey()
+ {
+ var issuer = IssuerFor(Target("portal.test", 8443));
+ using (var clientKey = RSA.Create(2048))
+ {
+ var response = issuer.Issue(LeafRequest("portal.test", 8443, clientKey.ExportSubjectPublicKeyInfo()));
+
+ Assert.True(response.Success, response.ErrorMessage);
+ var leaf = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(response.LeafDerBase64));
+ Assert.Contains("portal.test", leaf.Subject);
+ Assert.False(leaf.HasPrivateKey); // the private half never left the client
+
+ var san = leaf.Extensions["2.5.29.17"];
+ Assert.NotNull(san);
+ Assert.Contains("portal.test", san.Format(true));
+
+ // The chain head the client must see in its machine trust roots.
+ var authority = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(response.AuthorityDerBase64));
+ Assert.Contains(ProxyLoopbackTls.AuthorityCommonName, authority.Subject);
+ Assert.Equal(authority.Thumbprint, response.AuthorityThumbprint);
+ Assert.Equal(authority.Subject, leaf.Issuer);
+ }
+ }
+
+ [Fact]
+ public void RefusesHostsNoProxiedWorkspaceAllowlists()
+ {
+ var issuer = IssuerFor(Target("portal.test", 8443));
+
+ var wrongHost = issuer.Issue(LeafRequest("evil.example", 8443));
+ Assert.False(wrongHost.Success);
+ Assert.Equal(ProxyFetchErrorCodes.NotAllowed, wrongHost.ErrorCode);
+
+ var wrongPort = issuer.Issue(LeafRequest("portal.test", 9999));
+ Assert.False(wrongPort.Success);
+ Assert.Equal(ProxyFetchErrorCodes.NotAllowed, wrongPort.ErrorCode);
+ }
+
+ [Fact]
+ public void FailsClosedWhenTheAllowlistHasNeverResolved()
+ {
+ var allowlist = new ProxyServerAllowlist(
+ () => throw new InvalidOperationException("configuration lookup failed"),
+ Sink);
+ var issuer = new ProxyLeafIssuer(allowlist, () => Authority.Create(Sink), Sink);
+
+ var response = issuer.Issue(LeafRequest("portal.test", 443));
+ Assert.False(response.Success);
+ Assert.Equal(ProxyFetchErrorCodes.NotAllowed, response.ErrorCode);
+ }
+
+ [Theory]
+ [InlineData("", 443)]
+ [InlineData("portal.test", 0)]
+ [InlineData("portal.test", -1)]
+ [InlineData("portal.test", 70000)]
+ public void RejectsMalformedOriginsWithBadRequest(string host, int port)
+ {
+ var issuer = IssuerFor(Target("portal.test", 443));
+ var response = issuer.Issue(LeafRequest(host, port));
+ Assert.False(response.Success);
+ Assert.Equal(ProxyFetchErrorCodes.BadRequest, response.ErrorCode);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("this is not base64!!")]
+ [InlineData("aW52YWxpZA==")] // valid base64, garbage bytes: not an SPKI
+ public void RejectsUnusableClientPublicKeysWithBadRequest(string spkiBase64)
+ {
+ var issuer = IssuerFor(Target("portal.test", 443));
+ var request = LeafRequest("portal.test", 443);
+ request.SpkiBase64 = spkiBase64;
+
+ var response = issuer.Issue(request);
+ Assert.False(response.Success);
+ Assert.Equal(ProxyFetchErrorCodes.BadRequest, response.ErrorCode);
+ }
+
+ [Fact]
+ public void AuthorityUnavailableAnswersNoAuthorityNotATHrow()
+ {
+ var allowlist = new ProxyServerAllowlist(
+ () => new List> { WorkspaceProperties(Target("portal.test", 443)) },
+ Sink);
+ var issuer = new ProxyLeafIssuer(
+ allowlist,
+ () => throw new InvalidOperationException("cannot open the machine certificate store"),
+ Sink);
+
+ var response = issuer.Issue(LeafRequest("portal.test", 443));
+ Assert.False(response.Success);
+ Assert.Equal(ProxyFetchErrorCodes.NoAuthority, response.ErrorCode);
+ }
+
+ // ---------------- requester-side correlation ----------------
+
+ [Fact]
+ public async Task LeafCorrelationCompletesOnTheSuccessResponse()
+ {
+ var correlation = new ProxyLeafCorrelation();
+ var pending = correlation.Begin("id1", TimeSpan.FromSeconds(30));
+
+ Assert.True(correlation.Feed(new ProxyLeafResponsePayload
+ {
+ CorrelationId = "id1",
+ Success = true,
+ LeafDerBase64 = "AAEC",
+ AuthorityDerBase64 = "BAAE",
+ AuthorityThumbprint = "AA",
+ }));
+
+ var result = await pending;
+ Assert.True(result.Success);
+ Assert.Equal("AA", result.AuthorityThumbprint);
+ }
+
+ [Fact]
+ public async Task LeafCorrelationCompletesOnTheFailureResponse()
+ {
+ var correlation = new ProxyLeafCorrelation();
+ var pending = correlation.Begin("id2", TimeSpan.FromSeconds(30));
+
+ correlation.Feed(new ProxyLeafResponsePayload
+ {
+ CorrelationId = "id2",
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.NotAllowed,
+ ErrorMessage = "no workspace allowlists this origin",
+ });
+
+ var result = await pending;
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.NotAllowed, result.ErrorCode);
+ }
+
+ [Fact]
+ public async Task LeafCorrelationCompletesWithTimeoutWhenNoAnswerArrives()
+ {
+ var correlation = new ProxyLeafCorrelation();
+ var pending = correlation.Begin("id3", TimeSpan.FromMilliseconds(50));
+
+ var result = await pending;
+ Assert.False(result.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Timeout, result.ErrorCode);
+ }
+
+ [Fact]
+ public void LeafCorrelationIgnoresFramesForUnknownCorrelationIds()
+ {
+ var correlation = new ProxyLeafCorrelation();
+ Assert.False(correlation.Feed(new ProxyLeafResponsePayload { CorrelationId = "nobody-waits" }));
+ Assert.False(correlation.Feed(null));
+ }
+ }
+}
+
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index d045eec..7e0c90e 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -37,6 +37,8 @@
+
+
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index 511c4f1..a2d4fc2 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -19,6 +19,11 @@ internal static class ProxyMessageIds
{
internal const string FetchRequestId = "WebContent.Proxy.FetchRequest";
internal const string FetchResponseId = "WebContent.Proxy.FetchResponse";
+
+ /// ADR-0003: client asks the Event Server to mint a loopback leaf for one
+ /// allowlisted origin, carrying the client-generated SubjectPublicKeyInfo.
+ internal const string LeafRequestId = "WebContent.Proxy.LeafRequest";
+ internal const string LeafResponseId = "WebContent.Proxy.LeafResponse";
}
///
@@ -68,6 +73,12 @@ internal static class ProxyFetchErrorCodes
/// no per-target "accept untrusted certificate" override, so the fetch is refused.
///
internal const string TlsValidation = "TlsValidation";
+
+ ///
+ /// Ticket #56 (ADR-0003): the Event Server could not load or create the deployment CA,
+ /// so no loopback leaf certificate can be minted.
+ ///
+ internal const string NoAuthority = "NoAuthority";
}
///
@@ -209,6 +220,48 @@ internal class ProxyFetchResult
internal string ErrorMessage;
}
+ ///
+ /// Ticket #56 (ADR-0003): one loopback-leaf mint request. The client generates the leaf key
+ /// pair and sends only the public half (DER SubjectPublicKeyInfo, base64) — no private key
+ /// ever crosses the wire. Host/Port identify the CONNECT origin; the Event Server mints only
+ /// for origins some proxied workspace allowlists (the allowlist IS the minting policy).
+ /// One JSON string on the wire like every other proxy payload (#54).
+ ///
+ [Serializable]
+ public class ProxyLeafRequestPayload
+ {
+ public string CorrelationId;
+
+ /// CONNECT target host as the browser resolved it (IdnHost form).
+ public string Host;
+
+ /// CONNECT target port; the allowlist gate matches scheme+host+port exactly.
+ public int Port;
+
+ public string SpkiBase64;
+ }
+
+ ///
+ /// Ticket #56 (ADR-0003): the leaf exchange's single response frame. Success carries the minted
+ /// leaf DER (no private key — the client attaches its own) plus the deployment CA certificate
+ /// and its thumbprint, so the client can verify its machine trust roots hold the CA and show
+ /// actionable guidance when it does not.
+ ///
+ [Serializable]
+ public class ProxyLeafResponsePayload
+ {
+ public string CorrelationId;
+ public bool Success;
+
+ public string LeafDerBase64;
+ public string AuthorityDerBase64;
+ public string AuthorityThumbprint;
+
+ // Failure fields.
+ public string ErrorCode;
+ public string ErrorMessage;
+ }
+
///
/// Bounds the fetcher enforces per request (ticket #53 acceptance: connect timeout, read
/// timeout, maximum response size).
diff --git a/src/WebContent/ProxyFetchWireCodec.cs b/src/WebContent/ProxyFetchWireCodec.cs
index b400987..cc553cb 100644
--- a/src/WebContent/ProxyFetchWireCodec.cs
+++ b/src/WebContent/ProxyFetchWireCodec.cs
@@ -28,6 +28,12 @@ internal static class ProxyFetchWireCodec
private static readonly DataContractJsonSerializer FrameSerializer =
new DataContractJsonSerializer(typeof(ProxyFetchResponsePayload));
+ private static readonly DataContractJsonSerializer LeafRequestSerializer =
+ new DataContractJsonSerializer(typeof(ProxyLeafRequestPayload));
+
+ private static readonly DataContractJsonSerializer LeafResponseSerializer =
+ new DataContractJsonSerializer(typeof(ProxyLeafResponsePayload));
+
/// Encode a request payload for Message.Data.
internal static string ToWire(ProxyFetchRequestPayload payload)
{
@@ -67,6 +73,40 @@ internal static ProxyFetchResponsePayload FrameFromWire(object data)
return Deserialize(json, FrameSerializer);
}
+ /// Encode a leaf-mint request for Message.Data (ADR-0003).
+ internal static string ToWire(ProxyLeafRequestPayload request)
+ {
+ return Serialize(request, LeafRequestSerializer);
+ }
+
+ /// Encode a leaf-mint response for Message.Data (ADR-0003).
+ internal static string ToWire(ProxyLeafResponsePayload response)
+ {
+ return Serialize(response, LeafResponseSerializer);
+ }
+
+ internal static ProxyLeafRequestPayload LeafRequestFromWire(object data)
+ {
+ var json = data as string;
+ if (json == null)
+ {
+ return null;
+ }
+
+ return Deserialize(json, LeafRequestSerializer);
+ }
+
+ internal static ProxyLeafResponsePayload LeafResponseFromWire(object data)
+ {
+ var json = data as string;
+ if (json == null)
+ {
+ return null;
+ }
+
+ return Deserialize(json, LeafResponseSerializer);
+ }
+
private static string Serialize(object payload, DataContractJsonSerializer serializer)
{
using (var stream = new MemoryStream())
diff --git a/src/WebContent/ProxyLeafCorrelation.cs b/src/WebContent/ProxyLeafCorrelation.cs
new file mode 100644
index 0000000..4aa20c6
--- /dev/null
+++ b/src/WebContent/ProxyLeafCorrelation.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace WebContent
+{
+ ///
+ /// Requester-side protocol state for the leaf-mint exchange (#56, ADR-0003). Unlike the fetch
+ /// stream this exchange is exactly one response frame, so the correlator is a pending-task
+ /// table with deadlines: Feed completes the waiting task with the first frame that carries a
+ /// known correlation id, and an unanswered exchange completes as a Timeout failure rather
+ /// than staying pending forever.
+ ///
+ /// Thread-safe: Feed runs on the message receiver thread, Begin on the listener's thread.
+ ///
+ internal sealed class ProxyLeafCorrelation
+ {
+ private sealed class Pending
+ {
+ internal string CorrelationId;
+ internal CancellationTokenSource Deadline;
+ internal TaskCompletionSource Completion;
+ internal bool Completed;
+ }
+
+ private readonly object _sync = new object();
+ private readonly Dictionary _pending = new Dictionary(StringComparer.Ordinal);
+
+ /// Register a pending leaf request; the task completes on the response frame or
+ /// the deadline.
+ internal Task Begin(string correlationId, TimeSpan timeout)
+ {
+ lock (_sync)
+ {
+ var pending = new Pending
+ {
+ CorrelationId = correlationId,
+ Deadline = new CancellationTokenSource(timeout),
+ Completion = new TaskCompletionSource(),
+ };
+
+ pending.Deadline.Token.Register(() =>
+ Complete(pending, new ProxyLeafResponsePayload
+ {
+ CorrelationId = correlationId,
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Timeout,
+ ErrorMessage = "No leaf-mint response within the response deadline.",
+ }));
+
+ _pending[correlationId] = pending;
+ return pending.Completion.Task;
+ }
+ }
+
+ /// Route one response frame; true when a pending exchange owned it.
+ internal bool Feed(ProxyLeafResponsePayload response)
+ {
+ if (response == null || string.IsNullOrEmpty(response.CorrelationId))
+ {
+ return false;
+ }
+
+ lock (_sync)
+ {
+ if (!_pending.TryGetValue(response.CorrelationId, out var pending) || pending.Completed)
+ {
+ return false;
+ }
+
+ Complete(pending, response);
+ return true;
+ }
+ }
+
+ /// Drop a pending request locally (the listener lost interest).
+ internal void Abandon(string correlationId)
+ {
+ lock (_sync)
+ {
+ if (_pending.TryGetValue(correlationId, out var pending))
+ {
+ Complete(pending, new ProxyLeafResponsePayload
+ {
+ CorrelationId = correlationId,
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The leaf-mint request was abandoned locally.",
+ });
+ }
+ }
+ }
+
+ private void Complete(Pending pending, ProxyLeafResponsePayload response)
+ {
+ lock (_sync)
+ {
+ if (pending.Completed)
+ {
+ return;
+ }
+
+ pending.Completed = true;
+ _pending.Remove(pending.CorrelationId);
+ }
+
+ try
+ {
+ pending.Completion.TrySetResult(response);
+ }
+ finally
+ {
+ pending.Deadline.Cancel();
+ pending.Deadline.Dispose();
+ }
+ }
+ }
+}
diff --git a/src/WebContent/ProxyLeafIssuer.cs b/src/WebContent/ProxyLeafIssuer.cs
new file mode 100644
index 0000000..bbfc79a
--- /dev/null
+++ b/src/WebContent/ProxyLeafIssuer.cs
@@ -0,0 +1,142 @@
+using System;
+
+namespace WebContent
+{
+ ///
+ /// Ticket #56 (ADR-0003): the Event Server half of the loopback-leaf mint. A Smart Client
+ /// whose loopback listener is terminating an allowlisted https CONNECT asks the Event Server
+ /// to sign a leaf certificate for that origin; the request carries only the client-generated
+ /// SubjectPublicKeyInfo, so the leaf's private half never leaves the client machine.
+ ///
+ /// The workspace allowlist IS the minting policy: an origin is mintable exactly when the same
+ /// union snapshot that gates fetches allowlists it (fail-closed before its first successful
+ /// resolve, like the fetcher). The authority is the long-lived deployment CA held in the
+ /// Event Server machine's LocalMachine\Personal store; when it cannot be loaded or created
+ /// (store permissions, a service account that is not LocalSystem), the answer is the
+ /// NoAuthority error, never an exception on the messaging thread.
+ ///
+ internal sealed class ProxyLeafIssuer
+ {
+ private readonly ProxyServerAllowlist _allowlist;
+ private readonly Func _authorityProvider;
+ private readonly Action _log;
+
+ /// The same union-gated allowlist that gates fetches.
+ /// Supplies the deployment authority; throws when it is
+ /// unavailable, which is answered as NoAuthority rather than propagating.
+ internal ProxyLeafIssuer(ProxyServerAllowlist allowlist, Func authorityProvider, Action log)
+ {
+ _allowlist = allowlist ?? throw new ArgumentNullException(nameof(allowlist));
+ _authorityProvider = authorityProvider ?? throw new ArgumentNullException(nameof(authorityProvider));
+ _log = log ?? delegate { };
+ }
+
+ ///
+ /// Validate and mint. Never throws: every rejection becomes a failure payload with a
+ /// machine-readable code (BadRequest / NotAllowed / NoAuthority / Error).
+ ///
+ internal ProxyLeafResponsePayload Issue(ProxyLeafRequestPayload request)
+ {
+ if (request == null || string.IsNullOrEmpty(request.CorrelationId))
+ {
+ return Failure(null, ProxyFetchErrorCodes.BadRequest, "The leaf request carried no usable payload.");
+ }
+
+ // Host/Port bounds: the CONNECT origin as the browser resolved it. Port must be a real
+ // TCP port; a blank host would mint a certificate for nothing.
+ if (string.IsNullOrWhiteSpace(request.Host) || request.Port <= 0 || request.Port > 65535)
+ {
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest,
+ "The leaf request origin (host and port) is malformed.");
+ }
+
+ byte[] spki;
+ if (string.IsNullOrEmpty(request.SpkiBase64))
+ {
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest,
+ "The leaf request carried no client public key.");
+ }
+
+ try
+ {
+ spki = Convert.FromBase64String(request.SpkiBase64);
+ }
+ catch (FormatException)
+ {
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest,
+ "The leaf request carried a public key that is not valid base64.");
+ }
+
+ // The minting gate: exactly the origins some proxied workspace allowlists, matched on
+ // scheme+host+port like every fetch. The authority signs for the origin the allowlist
+ // blessed, so a crafted host can never acquire a trusted certificate for elsewhere.
+ Uri origin;
+ if (!Uri.TryCreate($"https://{request.Host}:{request.Port}/", UriKind.Absolute, out origin))
+ {
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest,
+ "The leaf request origin (host and port) is malformed.");
+ }
+
+ string refuseReason;
+ if (!_allowlist.IsAllowed(origin, out refuseReason))
+ {
+ _log($"Leaf mint refused for {request.Host}:{request.Port} - {refuseReason}");
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.NotAllowed, refuseReason);
+ }
+
+ Authority authority;
+ try
+ {
+ authority = _authorityProvider();
+ }
+ catch (Exception exception)
+ {
+ _log($"Loopback proxy authority unavailable: {exception.Message}");
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.NoAuthority,
+ "The Event Server cannot load or create the proxy certificate authority.");
+ }
+
+ if (authority == null)
+ {
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.NoAuthority,
+ "The Event Server has no proxy certificate authority.");
+ }
+
+ try
+ {
+ var leafDer = authority.MintLeafForPublicKey(request.Host, spki);
+ var authorityDer = authority.Certificate.RawData;
+ _log($"Minted loopback proxy leaf for {request.Host}:{request.Port}.");
+ return new ProxyLeafResponsePayload
+ {
+ CorrelationId = request.CorrelationId,
+ Success = true,
+ LeafDerBase64 = Convert.ToBase64String(leafDer),
+ AuthorityDerBase64 = Convert.ToBase64String(authorityDer),
+ AuthorityThumbprint = authority.Certificate.Thumbprint,
+ };
+ }
+ catch (ArgumentException argumentException)
+ {
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest, argumentException.Message);
+ }
+ catch (Exception exception)
+ {
+ _log($"Loopback proxy leaf mint failed for {request.Host}:{request.Port}: {exception.Message}");
+ return Failure(request.CorrelationId, ProxyFetchErrorCodes.Error,
+ "The Event Server could not mint the loopback proxy leaf: " + exception.Message);
+ }
+ }
+
+ private static ProxyLeafResponsePayload Failure(string correlationId, string errorCode, string message)
+ {
+ return new ProxyLeafResponsePayload
+ {
+ CorrelationId = correlationId,
+ Success = false,
+ ErrorCode = errorCode,
+ ErrorMessage = message,
+ };
+ }
+ }
+}
diff --git a/src/WebContent/ProxyLoopbackAuthority.cs b/src/WebContent/ProxyLoopbackAuthority.cs
index d7eac0c..c4ec0ea 100644
--- a/src/WebContent/ProxyLoopbackAuthority.cs
+++ b/src/WebContent/ProxyLoopbackAuthority.cs
@@ -80,9 +80,37 @@ internal ProxyLoopbackLeafOwner CreateLeaf(string host)
// authentication, and the container goes away when the owner is disposed.
var key = NewPersistedProvider(
"WCProxyLeaf-" + ShortHash(host) + "-" + Guid.NewGuid().ToString("N").Substring(0, 8));
- var subjectDer = Der.Name(host);
- var spkiDer = Der.SubjectPublicKeyInfo(key);
+ try
+ {
+ var certificateDer = MintLeafForPublicKey(host, Der.SubjectPublicKeyInfo(key));
+ var certificate = new X509Certificate2(certificateDer);
+ certificate.PrivateKey = key;
+ return new ProxyLoopbackLeafOwner(certificate, key);
+ }
+ catch
+ {
+ key.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// ADR-0003: signs a leaf for this host around a caller-supplied SubjectPublicKeyInfo —
+ /// the Event Server mint path, where the private half lives on the requesting client.
+ /// Validation here is structural (the Framework has no SPKI parser); an unusable public
+ /// key surfaces as ArgumentException, which the issuer maps to a BadRequest refusal.
+ ///
+ internal byte[] MintLeafForPublicKey(string host, byte[] subjectPublicKeyInfoDer)
+ {
+ if (subjectPublicKeyInfoDer == null ||
+ subjectPublicKeyInfoDer.Length < 50 ||
+ subjectPublicKeyInfoDer.Length > 4096 ||
+ subjectPublicKeyInfoDer[0] != 0x30)
+ {
+ throw new ArgumentException("The client public key is not a usable SubjectPublicKeyInfo.", nameof(subjectPublicKeyInfoDer));
+ }
+ var subjectDer = Der.Name(host);
var notBefore = DateTime.UtcNow.AddDays(-1);
var notAfter = notBefore.AddYears(2);
@@ -90,21 +118,18 @@ internal ProxyLoopbackLeafOwner CreateLeaf(string host)
Der.Tagged(0xA0, Der.Integer(2)),
Der.Integer(RandomPositiveBytes(8)),
Der.Sha256RsaAlgorithmIdentifier(),
- _authorityNameDer, // issuer = the session CA
+ _authorityNameDer, // issuer = the deployment CA
Der.Validity(notBefore, notAfter),
subjectDer,
- spkiDer,
+ subjectPublicKeyInfoDer,
Der.Extensions(
Der.BasicConstraints(false),
Der.LeafKeyUsage(),
Der.ServerAuthEku(),
- Der.SubjectKeyIdentifier(spkiDer),
+ Der.SubjectKeyIdentifier(subjectPublicKeyInfoDer),
Der.SubjectAlternativeName(host)));
- var certificateDer = Sign(tbs, _authorityKey);
- var certificate = new X509Certificate2(certificateDer);
- certificate.PrivateKey = key;
- return new ProxyLoopbackLeafOwner(certificate, key);
+ return Sign(tbs, _authorityKey);
}
public void Dispose()
@@ -411,55 +436,85 @@ private Authority()
internal ProxyLoopbackLeafOwner CreateLeaf(string host)
{
var key = RSA.Create(2048);
- var request = new CertificateRequest(
- new X500DistinguishedName("CN=" + host),
- key,
- HashAlgorithmName.SHA256,
- RSASignaturePadding.Pkcs1);
- request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
- request.CertificateExtensions.Add(new X509KeyUsageExtension(
- X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
- false));
- request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
- new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
- true));
+ var certificateDer = MintLeafForPublicKey(host, key.ExportSubjectPublicKeyInfo());
+ var certificate = X509CertificateLoader.LoadCertificate(certificateDer);
+
+ // The request key is not attached automatically when the issuer's own key is not
+ // copyable, so attach it here; either way the result must hold a private key.
+ var withKey = certificate.HasPrivateKey ? certificate : certificate.CopyWithPrivateKey(key);
+
+ // Schannel refuses ephemeral CNG keys for server authentication; the PFX roundtrip
+ // lands the key in a persisted form it accepts (same recipe as production).
+ var password = Guid.NewGuid().ToString("N");
+ var portable = withKey.Export(X509ContentType.Pkcs12, password);
+ var usable = X509CertificateLoader.LoadPkcs12(portable, password);
+ if (!ReferenceEquals(withKey, certificate))
+ {
+ withKey.Dispose();
+ }
- var builder = new SubjectAlternativeNameBuilder();
- IPAddress address;
- if (IPAddress.TryParse(host, out address))
+ return new ProxyLoopbackLeafOwner(usable, key);
+ }
+
+ ///
+ /// ADR-0003: signs a leaf for this host around a caller-supplied SubjectPublicKeyInfo —
+ /// the Event Server mint path, where the private half lives on the requesting client. An
+ /// unusable public key surfaces as ArgumentException, which the issuer maps to a
+ /// BadRequest refusal.
+ ///
+ internal byte[] MintLeafForPublicKey(string host, byte[] subjectPublicKeyInfoDer)
+ {
+ var publicKey = RSA.Create();
+ try
{
- builder.AddIpAddress(address);
+ publicKey.ImportSubjectPublicKeyInfo(subjectPublicKeyInfoDer ?? Array.Empty(), out _);
}
- else
+ catch (Exception exception)
{
- builder.AddDnsName(host);
+ publicKey.Dispose();
+ throw new ArgumentException("The client public key is not a usable RSA SubjectPublicKeyInfo.", nameof(subjectPublicKeyInfoDer), exception);
}
- request.CertificateExtensions.Add(builder.Build(false));
-
- using (var certificate = request.Create(
- _authorityCertificate,
- DateTimeOffset.UtcNow.AddDays(-1),
- DateTimeOffset.UtcNow.AddDays(730),
- RandomNumberGenerator.GetBytes(8)))
+ try
{
- // The request key is not attached automatically when the issuer's own key is not
- // copyable, so attach it here; either way the result must hold a private key.
- var withKey = certificate.HasPrivateKey
- ? certificate
- : certificate.CopyWithPrivateKey(key);
-
- // Schannel refuses ephemeral CNG keys for server authentication; the PFX roundtrip
- // lands the key in a persisted form it accepts (same recipe as production).
- var password = Guid.NewGuid().ToString("N");
- var portable = withKey.Export(X509ContentType.Pkcs12, password);
- var usable = X509CertificateLoader.LoadPkcs12(portable, password);
- if (!ReferenceEquals(withKey, certificate))
+ var request = new CertificateRequest(
+ new X500DistinguishedName("CN=" + host),
+ publicKey,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
+ false));
+ request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
+ new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
+ true));
+
+ var builder = new SubjectAlternativeNameBuilder();
+ IPAddress address;
+ if (IPAddress.TryParse(host, out address))
+ {
+ builder.AddIpAddress(address);
+ }
+ else
{
- withKey.Dispose();
+ builder.AddDnsName(host);
}
- return new ProxyLoopbackLeafOwner(usable, key);
+ request.CertificateExtensions.Add(builder.Build(false));
+
+ using (var certificate = request.Create(
+ _authorityCertificate,
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddDays(730),
+ RandomNumberGenerator.GetBytes(8)))
+ {
+ return certificate.RawData;
+ }
+ }
+ finally
+ {
+ publicKey.Dispose();
}
}
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index 6a5ad28..d258594 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -113,6 +113,8 @@
+
+
diff --git a/src/WebContent/WebContentPluginDefinition.cs b/src/WebContent/WebContentPluginDefinition.cs
index 68c2294..881ec7c 100644
--- a/src/WebContent/WebContentPluginDefinition.cs
+++ b/src/WebContent/WebContentPluginDefinition.cs
@@ -77,6 +77,8 @@ public override string VersionString
{
ProxyMessageIds.FetchRequestId,
ProxyMessageIds.FetchResponseId,
+ ProxyMessageIds.LeafRequestId,
+ ProxyMessageIds.LeafResponseId,
};
public override List WorkSpacePlugins
From 171d04cd5c6b364b050afcfe17b699af7a0db86f Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 14:26:56 -0700
Subject: [PATCH 024/187] feat(proxy): #56 Event Server answers LeafRequest
over the shared messaging plugin (ADR-0003) - unicast leaf replies (never
broadcast), deployment CA lazily loaded/created in LocalMachine\\Personal
under a fixed machine key container with per-request retry until provisioned
---
.../ProxyFetchReplyGuardTests.cs | 15 ++
src/WebContent/ProxyFetchBackgroundPlugin.cs | 123 ++++++++++++
src/WebContent/ProxyLoopbackAuthority.cs | 178 ++++++++++++++++--
3 files changed, 305 insertions(+), 11 deletions(-)
diff --git a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
index 967623b..85171c9 100644
--- a/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
+++ b/src/WebContent.Tests/ProxyFetchReplyGuardTests.cs
@@ -56,6 +56,21 @@ public void ResponseTransmitsNeverFallBackToNullDestinationBroadcast()
Assert.Contains("ErrorMessage = message, })), requester, null, null);", collapsed);
}
+ [Fact]
+ public void LeafMintAnswersAreUnicastToTheRequesterNeverBroadcast()
+ {
+ // ADR-0003: the minted certificate and the CA ride the same unicast discipline as
+ // fetch frames - a null-destination leaf response would deliver signing material to
+ // every host subscribed to the leaf response id.
+ var fetcher = ReadFetcher();
+ var collapsed = System.Text.RegularExpressions.Regex.Replace(fetcher, @"\s+", " ");
+
+ Assert.Contains("ProxyFetchWireCodec.LeafRequestFromWire(message?.Data)", fetcher);
+ Assert.Contains("new CommunicationIdFilter(ProxyMessageIds.LeafRequestId)", fetcher);
+ Assert.Contains("if (requester == null)", fetcher);
+ Assert.Contains("ProxyMessageIds.LeafResponseId, ProxyFetchWireCodec.ToWire(response)), requester, null, null);", collapsed);
+ }
+
[Fact]
public void BothSidesCarryPayloadsAsJsonStringsNeverAsPluginTypes()
{
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
index 48c3d4e..f1c434c 100644
--- a/src/WebContent/ProxyFetchBackgroundPlugin.cs
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -31,8 +31,18 @@ internal sealed class ProxyFetchBackgroundPlugin : BackgroundPlugin
private ServerId _serverId;
private MessageCommunication _messageCommunication;
private object _requestFilterRegistration;
+ private object _leafFilterRegistration;
private SemaphoreSlim _fetchSlots;
private ProxyServerAllowlist _allowlist;
+ private ProxyLeafIssuer _leafIssuer;
+
+ ///
+ /// ADR-0003: the deployment CA is loaded/created lazily on the first leaf request and
+ /// then reused - its private key lives in this machine's LocalMachine\Personal store and
+ /// outlives the service process.
+ ///
+ private readonly object _authorityGate = new object();
+ private Authority _deploymentAuthority;
public override Guid Id => PluginId;
@@ -71,6 +81,16 @@ public override void Init()
ResolveWorkspacePropertySnapshots,
message => EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), message));
+ // ADR-0003: the same plugin answers leaf-mint requests for the loopback TLS
+ // termination; the same allowlist union gates minting exactly like fetching.
+ _leafIssuer = new ProxyLeafIssuer(
+ _allowlist,
+ ResolveDeploymentAuthority,
+ message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message));
+ _leafFilterRegistration = _messageCommunication.RegisterCommunicationFilter(
+ OnProxyLeafRequest,
+ new CommunicationIdFilter(ProxyMessageIds.LeafRequestId));
+
EnvironmentManager.Instance.Log(
false,
nameof(ProxyFetchBackgroundPlugin),
@@ -99,7 +119,21 @@ public override void Close()
}
}
+ if (_messageCommunication != null && _leafFilterRegistration != null)
+ {
+ try
+ {
+ _messageCommunication.UnRegisterCommunicationFilter(_leafFilterRegistration);
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy leaf filter detach failed: {exception.Message}");
+ }
+ }
+
_requestFilterRegistration = null;
+ _leafFilterRegistration = null;
+ _leafIssuer = null;
_messageCommunication = null;
// Deliberately no MessageCommunicationManager.Stop: the messaging session is
@@ -292,6 +326,95 @@ private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetc
}
}
+ ///
+ /// ADR-0003: answer one loopback-leaf mint request. Issuance is local CPU plus a store
+ /// lookup - fast enough for the messaging receive thread, unlike the fetch path. The
+ /// response is unicasted to the requester endpoint exactly like fetch frames: a null
+ /// destination would broadcast a certificate to every subscribed host.
+ ///
+ private object OnProxyLeafRequest(Message message, FQID destination, FQID source)
+ {
+ var payload = ProxyFetchWireCodec.LeafRequestFromWire(message?.Data);
+ if (payload == null || string.IsNullOrEmpty(payload.CorrelationId))
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Leaf request carried no usable payload; refused.");
+ return null;
+ }
+
+ var requester = source ?? message?.ExternalMessageSourceEndPoint;
+ if (requester == null)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Leaf request {payload.CorrelationId} carried no requester endpoint; its response is logged rather than broadcast.");
+ return null;
+ }
+
+ var communication = _messageCommunication;
+ if (communication == null)
+ {
+ return null;
+ }
+
+ ProxyLeafResponsePayload response;
+ try
+ {
+ response = _leafIssuer.Issue(payload);
+ }
+ catch (Exception exception)
+ {
+ // Issue is written not to throw; anything escaping still answers as a failure
+ // frame rather than leaving the requester to time out.
+ response = new ProxyLeafResponsePayload
+ {
+ CorrelationId = payload.CorrelationId,
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = exception.Message,
+ };
+ }
+
+ try
+ {
+ communication.TransmitMessage(
+ new Message(ProxyMessageIds.LeafResponseId, ProxyFetchWireCodec.ToWire(response)),
+ requester,
+ null,
+ null);
+ }
+ catch (Exception transmitException)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Leaf response {response.CorrelationId} not sent: {transmitException.Message}");
+ }
+
+ return null;
+ }
+
+ ///
+ /// ADR-0003: the long-lived deployment CA for this Event Server machine, loaded from (or
+ /// created in) LocalMachine\Personal on first use. The private key never leaves this
+ /// machine's machine-level key storage; a service that cannot write that store answers
+ /// NoAuthority and recovers once an operator provisions the CA (the creation is retried
+ /// per request until it succeeds).
+ ///
+ private Authority ResolveDeploymentAuthority()
+ {
+ lock (_authorityGate)
+ {
+ if (_deploymentAuthority == null)
+ {
+ _deploymentAuthority = Authority.LoadOrCreateDeploymentAuthority(
+ message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message));
+ }
+
+ return _deploymentAuthority;
+ }
+ }
+
///
/// The endpoint to answer a request on. The explicit source FQID (rarely sent) wins; the
/// platform-filled ExternalMessageSourceEndPoint is the normal answer path. When neither
diff --git a/src/WebContent/ProxyLoopbackAuthority.cs b/src/WebContent/ProxyLoopbackAuthority.cs
index c4ec0ea..8e8fea1 100644
--- a/src/WebContent/ProxyLoopbackAuthority.cs
+++ b/src/WebContent/ProxyLoopbackAuthority.cs
@@ -45,11 +45,101 @@ internal static Authority Create(Action log)
return new Authority();
}
- private Authority()
+ /// Fixed CAPI machine container name for the deployment CA (ADR-0003).
+ internal const string DeploymentCaContainer = "WebContent-Proxy-CA";
+
+ ///
+ /// ADR-0003: the long-lived deployment authority of this Event Server machine. Loads the
+ /// existing CA from LocalMachine\Personal (any valid copy whose key this process can use)
+ /// and otherwise creates it: a machine-key-store CAPI container under a fixed name, signed
+ /// self, stored with its private key. Requires machine-level privileges (the Event Server
+ /// service's LocalSystem identity covers it); access failures propagate so the leaf
+ /// issuer can answer NoAuthority and retry provisioning on the next request.
+ ///
+ internal static Authority LoadOrCreateDeploymentAuthority(Action log)
{
- _authorityKey = NewPersistedProvider("WCProxyCA-" + Guid.NewGuid().ToString("N").Substring(0, 12));
- _authorityNameDer = Der.Name(ProxyLoopbackTls.AuthorityCommonName);
- _authoritySpkiDer = Der.SubjectPublicKeyInfo(_authorityKey);
+ using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
+ {
+ store.Open(OpenFlags.ReadWrite);
+
+ var now = DateTime.Now;
+ foreach (var existing in store.Certificates.Find(
+ X509FindType.FindBySubjectName, ProxyLoopbackTls.AuthorityCommonName, false))
+ {
+ if (existing.NotAfter <= now)
+ {
+ log($"Deployment proxy CA '{existing.Subject}' has expired; a fresh authority will be minted and the machine trust roots must be updated to match.");
+ continue;
+ }
+
+ if (!existing.HasPrivateKey)
+ {
+ continue;
+ }
+
+ RSACryptoServiceProvider key;
+ try
+ {
+ key = (RSACryptoServiceProvider)existing.PrivateKey;
+ }
+ catch (Exception)
+ {
+ // A CNG-backed copy would return a different provider type; this minting
+ // path signs through CAPI, so skip and let provisioning replace it.
+ continue;
+ }
+
+ log($"Loaded deployment proxy CA from LocalMachine\\Personal (thumbprint {existing.Thumbprint}).");
+ return new Authority(existing, key);
+ }
+
+ var parameters = new CspParameters
+ {
+ KeyContainerName = DeploymentCaContainer,
+ ProviderName = EnhancedProviderName,
+ ProviderType = EnhancedProviderType,
+ KeyNumber = (int)KeyNumber.Exchange,
+ Flags = CspProviderFlags.UseMachineKeyStore,
+ };
+ var createdKey = new RSACryptoServiceProvider(KeyBits, parameters);
+
+ // The deployment CA outlives the process: the container must survive disposal, and
+ // the store keeps the certificate+key together.
+ createdKey.PersistKeyInCsp = true;
+ try
+ {
+ var certificate = MintAuthorityCertificate(createdKey);
+ store.Add(certificate);
+ log($"Created deployment proxy CA in LocalMachine\\Personal (thumbprint {certificate.Thumbprint}); client machines must import this certificate into LocalMachine\\Root to trust proxied https targets.");
+ return new Authority(certificate, createdKey);
+ }
+ catch
+ {
+ try
+ {
+ createdKey.PersistKeyInCsp = false;
+ createdKey.Dispose();
+ }
+ catch { }
+
+ throw;
+ }
+ }
+ }
+
+ private Authority(X509Certificate2 certificate, RSACryptoServiceProvider key)
+ {
+ _authorityCertificate = certificate;
+ _authorityKey = key;
+ _authorityNameDer = certificate.SubjectName.RawData;
+ _authoritySpkiDer = Der.SubjectPublicKeyInfo(key);
+ }
+
+ /// Self-signed CA certificate DER signed by , key attached.
+ private static X509Certificate2 MintAuthorityCertificate(RSACryptoServiceProvider key)
+ {
+ var nameDer = Der.Name(ProxyLoopbackTls.AuthorityCommonName);
+ var spkiDer = Der.SubjectPublicKeyInfo(key);
var notBefore = DateTime.UtcNow.AddDays(-1);
var notAfter = notBefore.AddYears(10);
@@ -58,18 +148,26 @@ private Authority()
Der.Tagged(0xA0, Der.Integer(2)), // version v3
Der.Integer(RandomPositiveBytes(8)), // serial
Der.Sha256RsaAlgorithmIdentifier(),
- _authorityNameDer, // issuer = subject (self-signed)
+ nameDer, // issuer = subject (self-signed)
Der.Validity(notBefore, notAfter),
- _authorityNameDer,
- _authoritySpkiDer,
+ nameDer,
+ spkiDer,
Der.Extensions(
Der.BasicConstraints(true),
Der.AuthorityKeyUsage(),
- Der.SubjectKeyIdentifier(_authoritySpkiDer))); // no EKU by design
+ Der.SubjectKeyIdentifier(spkiDer))); // no EKU by design
- var certificateDer = Sign(tbs, _authorityKey);
- _authorityCertificate = new X509Certificate2(certificateDer);
- _authorityCertificate.PrivateKey = _authorityKey;
+ var certificate = new X509Certificate2(Sign(tbs, key));
+ certificate.PrivateKey = key;
+ return certificate;
+ }
+
+ private Authority()
+ {
+ _authorityKey = NewPersistedProvider("WCProxyCA-" + Guid.NewGuid().ToString("N").Substring(0, 12));
+ _authorityCertificate = MintAuthorityCertificate(_authorityKey);
+ _authorityNameDer = Der.Name(ProxyLoopbackTls.AuthorityCommonName);
+ _authoritySpkiDer = Der.SubjectPublicKeyInfo(_authorityKey);
}
internal X509Certificate2 Certificate => _authorityCertificate;
@@ -412,6 +510,64 @@ internal static Authority Create(Action log)
return new Authority();
}
+ ///
+ /// ADR-0003: load (or first-run create) the deployment CA in LocalMachine\Personal, the
+ /// modern-.NET counterpart of the Framework machine-container path. Production minting
+ /// runs on the Framework Event Server; this keeps both backends contract-complete.
+ ///
+ internal static Authority LoadOrCreateDeploymentAuthority(Action log)
+ {
+ using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
+ {
+ store.Open(OpenFlags.ReadWrite);
+
+ var now = DateTime.Now;
+ foreach (var existing in store.Certificates.Find(
+ X509FindType.FindBySubjectName, ProxyLoopbackTls.AuthorityCommonName, false))
+ {
+ if (existing.NotAfter > now && existing.HasPrivateKey)
+ {
+ log($"Loaded deployment proxy CA from LocalMachine\\Personal (thumbprint {existing.Thumbprint}).");
+ return new Authority(existing);
+ }
+ }
+
+ var key = RSA.Create(2048);
+ var request = new CertificateRequest(
+ new X500DistinguishedName("CN=" + ProxyLoopbackTls.AuthorityCommonName),
+ key,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
+ true));
+
+ using (var created = request.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddDays(3650)))
+ {
+ // Land the key in machine-level storage (the store Add persists it), matching
+ // the Framework path's machine key container.
+ var password = Guid.NewGuid().ToString("N");
+ var portable = created.Export(X509ContentType.Pkcs12, password);
+ var machineCopy = X509CertificateLoader.LoadPkcs12(
+ portable,
+ password,
+ X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet);
+ store.Add(machineCopy);
+ log($"Created deployment proxy CA in LocalMachine\\Personal (thumbprint {machineCopy.Thumbprint}); client machines must import this certificate into LocalMachine\\Root to trust proxied https targets.");
+ return new Authority(machineCopy);
+ }
+ }
+ }
+
+ private Authority(X509Certificate2 certificateWithKey)
+ {
+ _authorityCertificate = certificateWithKey;
+ _authorityKey = certificateWithKey.GetRSAPrivateKey();
+ }
+
private Authority()
{
_authorityKey = RSA.Create(2048);
From d4c671b757d06e8b9d68d9cba11bfb72aae55595 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 14:36:41 -0700
Subject: [PATCH 025/187] feat(proxy): #56 client half of ADR-0003 -
ProxyLoopbackClientKey generates the per-origin exchange key (CAPI
AT_KEYEXCHANGE container on Framework, PFX roundtrip on modern .NET),
ProxyLoopbackTls mints through the LeafRequest channel over ProxyFetchClient
and reports LocalMachine\\Root trust posture once per CA thumbprint, listener
no longer touches any certificate store (read-only ProxyLoopbackTrust
replaces install/remove)
---
.../ProxyLoopbackClientKeyTests.cs | 82 +++++++++
src/WebContent.Tests/ProxyLoopbackTlsTests.cs | 128 ++++++++-----
src/WebContent.Tests/WebContent.Tests.csproj | 2 +
.../Client/LoopbackProxyListener.cs | 55 ++----
src/WebContent/Client/ProxyLoopbackTrust.cs | 87 +++------
src/WebContent/ProxyFetchClient.cs | 90 +++++++++-
src/WebContent/ProxyLoopbackAuthority.cs | 9 +-
src/WebContent/ProxyLoopbackClientKey.cs | 98 ++++++++++
src/WebContent/ProxyLoopbackTls.cs | 168 ++++++++++++------
src/WebContent/WebContent.csproj | 1 +
10 files changed, 518 insertions(+), 202 deletions(-)
create mode 100644 src/WebContent.Tests/ProxyLoopbackClientKeyTests.cs
create mode 100644 src/WebContent/ProxyLoopbackClientKey.cs
diff --git a/src/WebContent.Tests/ProxyLoopbackClientKeyTests.cs b/src/WebContent.Tests/ProxyLoopbackClientKeyTests.cs
new file mode 100644
index 0000000..7ea69fd
--- /dev/null
+++ b/src/WebContent.Tests/ProxyLoopbackClientKeyTests.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #56 (ADR-0003): the client-side half of a loopback leaf - a locally generated key
+ /// whose public half alone is exportable for minting, and whose AttachLeaf lands the minted
+ /// certificate with a private key Schannel accepts for server authentication.
+ ///
+ public class ProxyLoopbackClientKeyTests
+ {
+ private static readonly Action Sink = _ => { };
+
+ [Fact]
+ public void TheLocalKeyExportsAPublicKeyThatReparses()
+ {
+ using (var key = ProxyLoopbackClientKey.Create())
+ {
+ Assert.NotNull(key.SubjectPublicKeyInfoDer);
+ Assert.True(key.SubjectPublicKeyInfoDer.Length > 50);
+
+ using (var reparsed = RSA.Create())
+ {
+ reparsed.ImportSubjectPublicKeyInfo(key.SubjectPublicKeyInfoDer, out _);
+ var modulus = reparsed.ExportParameters(false).Modulus;
+ Assert.NotNull(modulus);
+ }
+ }
+ }
+
+ [Fact]
+ public void TheExportedPublicKeyHoldsNoPrivateHalf()
+ {
+ // The SPKI is what crosses the wire; it must be public-only by construction.
+ using (var key = ProxyLoopbackClientKey.Create())
+ using (var reparsed = RSA.Create())
+ {
+ reparsed.ImportSubjectPublicKeyInfo(key.SubjectPublicKeyInfoDer, out _);
+ Assert.ThrowsAny(() => reparsed.ExportRSAPrivateKey());
+ }
+ }
+
+ [Fact]
+ public void AttachLeafProducesAUsableCertificateWithTheLocalPrivateHalf()
+ {
+ using (var authority = Authority.Create(Sink))
+ using (var key = ProxyLoopbackClientKey.Create())
+ {
+ var leafDer = authority.MintLeafForPublicKey("cam1.test", key.SubjectPublicKeyInfoDer);
+
+ using (var leaf = key.AttachLeaf(leafDer))
+ {
+ Assert.True(leaf.HasPrivateKey);
+ Assert.Equal("CN=cam1.test", leaf.Subject);
+ using (var rsa = leaf.GetRSAPrivateKey())
+ {
+ Assert.NotNull(rsa);
+ }
+ }
+ }
+ }
+
+ [Fact]
+ public void AttachLeafRejectsGarbageDerWithoutThrowingAwayTheKey()
+ {
+ using (var key = ProxyLoopbackClientKey.Create())
+ {
+ Assert.ThrowsAny(() => key.AttachLeaf(new byte[] { 1, 2, 3 }));
+
+ // A failed attach must not poison the key: a later good attach still works.
+ using (var authority = Authority.Create(Sink))
+ using (var leaf = key.AttachLeaf(authority.MintLeafForPublicKey("cam1.test", key.SubjectPublicKeyInfoDer)))
+ {
+ Assert.True(leaf.HasPrivateKey);
+ }
+ }
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyLoopbackTlsTests.cs b/src/WebContent.Tests/ProxyLoopbackTlsTests.cs
index aed0aed..cc88e16 100644
--- a/src/WebContent.Tests/ProxyLoopbackTlsTests.cs
+++ b/src/WebContent.Tests/ProxyLoopbackTlsTests.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.IO;
using System.Linq;
using System.Net;
using System.Net.Security;
@@ -15,21 +14,50 @@
namespace WebContent.Tests
{
///
- /// Ticket #56 (ADR-0002): the loopback TLS manager issues the ephemeral certificates that let
- /// the listener terminate a browser's CONNECT tunnel. The authority is a session CA; leaves
- /// are per-host, chain to that authority, carry the host's SAN and server-auth EKU, and hold a
- /// private key Schannel can actually use (proved by a real handshake, not just HasPrivateKey).
+ /// Ticket #56 (ADR-0003): the loopback TLS manager no longer owns a CA - it generates a local
+ /// key per origin, asks the Event Server (through an injected mint channel) to sign it, and
+ /// attaches the private half locally. Leaves chain to the deployment authority the channel
+ /// reports, carry the host's SAN and server-auth EKU, and hold a private key Schannel can
+ /// actually use (proved by a real handshake, not just HasPrivateKey).
///
public class ProxyLoopbackTlsTests : IDisposable
{
private static readonly Action Sink = _ => { };
- private readonly ProxyLoopbackTls _tls = new ProxyLoopbackTls(Sink);
+ private readonly Authority _authority = Authority.Create(Sink);
+ private readonly List<(string Host, int Port)> _mintCalls = new List<(string, int)>();
+ private Func _responder;
+ private ProxyLoopbackTls _tls;
+
+ public ProxyLoopbackTlsTests()
+ {
+ _responder = Mint;
+ _tls = new ProxyLoopbackTls(
+ (host, port, spki) => Task.FromResult(_responder(host, port, spki)),
+ Sink);
+ }
+
+ private ProxyLeafResponsePayload Mint(string host, int port, byte[] spki)
+ {
+ _mintCalls.Add((host, port));
+ return new ProxyLeafResponsePayload
+ {
+ Success = true,
+ LeafDerBase64 = Convert.ToBase64String(_authority.MintLeafForPublicKey(host, spki)),
+ AuthorityDerBase64 = Convert.ToBase64String(_authority.Certificate.RawData),
+ AuthorityThumbprint = _authority.Certificate.Thumbprint,
+ };
+ }
+
+ private Task LeafAsync(string host, int port = 8443)
+ {
+ return _tls.GetOrCreateLeafCertificateAsync(host, port);
+ }
[Fact]
- public void LeafChainsToTheSessionAuthorityWithServerAuthAndTheHostSan()
+ public async Task LeafChainsToTheDeploymentAuthorityWithServerAuthAndTheHostSan()
{
- var leaf = _tls.GetOrCreateLeafCertificate("cam1.test");
+ var leaf = await LeafAsync("cam1.test");
Assert.Equal("CN=cam1.test", leaf.Subject);
var eku = leaf.Extensions["2.5.29.37"] as X509EnhancedKeyUsageExtension;
@@ -44,21 +72,21 @@ public void LeafChainsToTheSessionAuthorityWithServerAuthAndTheHostSan()
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.Zero;
// ExtraStore alone never confers trust - the CA must be allowed to show as an unknown
- // root here; the live browser path installs it into the trusted-root store instead.
+ // root here; the deployed machine trust roots carry it instead.
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
- chain.ChainPolicy.ExtraStore.Add(_tls.AuthorityCertificate);
+ chain.ChainPolicy.ExtraStore.Add(_authority.Certificate);
Assert.True(chain.Build(leaf));
- // Element 0 = leaf, element 1 = the session authority at the root: exactly the two-link
- // chain Chromium will see once the authority is in the user's trusted-root store.
+ // Element 0 = leaf, element 1 = the deployment authority at the root: exactly the
+ // two-link chain Chromium sees once LocalMachine\Root holds the CA.
Assert.Equal(2, chain.ChainElements.Count);
- Assert.Equal(_tls.AuthorityCertificate.Thumbprint, chain.ChainElements[1].Certificate.Thumbprint);
+ Assert.Equal(_authority.Certificate.Thumbprint, chain.ChainElements[1].Certificate.Thumbprint);
}
[Fact]
- public void LeafForAnIpAddressHostCarriesAnIpAddressSan()
+ public async Task LeafForAnIpAddressHostCarriesAnIpAddressSan()
{
- var leaf = _tls.GetOrCreateLeafCertificate("10.20.30.40");
+ var leaf = await LeafAsync("10.20.30.40");
var san = leaf.Extensions["2.5.29.17"];
Assert.NotNull(san);
@@ -66,18 +94,37 @@ public void LeafForAnIpAddressHostCarriesAnIpAddressSan()
}
[Fact]
- public void LeavesAreCachedPerHostAndDistinctAcrossHosts()
+ public async Task LeavesAreCachedPerOriginAndDistinctAcrossOrigins()
{
- var first = _tls.GetOrCreateLeafCertificate("cam1.test");
- var again = _tls.GetOrCreateLeafCertificate("CAM1.TEST");
- var other = _tls.GetOrCreateLeafCertificate("cam2.test");
+ var first = await LeafAsync("cam1.test", 8443);
+ var again = await LeafAsync("CAM1.TEST", 8443);
+ var otherPort = await LeafAsync("cam1.test", 9443);
+ var otherHost = await LeafAsync("cam2.test", 8443);
Assert.Equal(first.Thumbprint, again.Thumbprint);
- Assert.NotEqual(first.Thumbprint, other.Thumbprint);
+ Assert.NotEqual(first.Thumbprint, otherPort.Thumbprint);
+ Assert.NotEqual(first.Thumbprint, otherHost.Thumbprint);
+
+ // One mint exchange per (host, port): a browser reconnect does not re-ask the server.
+ Assert.Equal(3, _mintCalls.Count);
}
[Fact]
- public async Task TerminatingATunnelPresentsAChainValidatingLeafAndRelaysBytes()
+ public async Task ARefusedMintFailsTheTunnelWithTheServerErrorCode()
+ {
+ _responder = (host, port, spki) => new ProxyLeafResponsePayload
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.NotAllowed,
+ ErrorMessage = "no workspace allowlists this origin",
+ };
+
+ var failure = await Assert.ThrowsAsync(() => LeafAsync("evil.example"));
+ Assert.Contains(ProxyFetchErrorCodes.NotAllowed, failure.Message);
+ }
+
+ [Fact]
+ public async Task TunnelTerminationPresentsTheMintedLeafAndRelaysBytes()
{
var serverSide = new TcpListener(IPAddress.Loopback, 0);
serverSide.Start();
@@ -122,10 +169,10 @@ public async Task TerminatingATunnelPresentsAChainValidatingLeafAndRelaysBytes()
var verify = new X509Chain();
verify.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
verify.ChainPolicy.UrlRetrievalTimeout = TimeSpan.Zero;
- verify.ChainPolicy.ExtraStore.Add(_tls.AuthorityCertificate);
+ verify.ChainPolicy.ExtraStore.Add(_authority.Certificate);
verify.Build(new X509Certificate2(certificate.GetRawCertData()));
- // The system store does not know the session CA (the store install is live-verified);
- // what must hold is that the chain itself is the two-link leaf->authority shape.
+ // The system store does not know the deployment CA (the LocalMachine\Root
+ // import is live-verified); what must hold is the two-link chain shape.
return chain != null;
}))
{
@@ -143,8 +190,11 @@ public async Task TerminatingATunnelPresentsAChainValidatingLeafAndRelaysBytes()
public async Task LeafPrivateKeySurvivesTheSchannelServerHandshake()
{
// The historical failure mode: certificates created in memory hold EPHEMERAL keys that
- // Schannel refuses for server authentication. A successful AuthenticateAsServer with
- // the manager's leaf is the pin that the key ended up in a usable persisted form.
+ // Schannel refuses for server authentication. A successful AuthenticateAsServer with a
+ // channel-minted leaf plus the locally generated key is the pin that the attached key
+ // ended up in a usable persisted form.
+ var leaf = await LeafAsync("cam1.test");
+
var serverSide = new TcpListener(IPAddress.Loopback, 0);
serverSide.Start();
var port = ((IPEndPoint)serverSide.LocalEndpoint).Port;
@@ -155,8 +205,7 @@ public async Task LeafPrivateKeySurvivesTheSchannelServerHandshake()
using (var socket = client.GetStream())
using (var tls = new SslStream(socket, false))
{
- await tls.AuthenticateAsServerAsync(
- _tls.GetOrCreateLeafCertificate("cam1.test"), false, SslProtocols.Tls12, false);
+ await tls.AuthenticateAsServerAsync(leaf, false, SslProtocols.Tls12, false);
return true;
}
});
@@ -175,28 +224,17 @@ await tls.AuthenticateAsServerAsync(
}
[Fact]
- public void AuthorityIsASelfSignedCertificateAuthority()
+ public async Task DisposeClosesTheManagerToFurtherLeafRequests()
{
- var authority = _tls.AuthorityCertificate;
- var basicConstraints = authority.Extensions["2.5.29.19"] as X509BasicConstraintsExtension;
- Assert.NotNull(basicConstraints);
- Assert.True(basicConstraints.CertificateAuthority);
- Assert.True(basicConstraints.Critical);
-
- var keyUsage = authority.Extensions["2.5.29.15"] as X509KeyUsageExtension;
- Assert.NotNull(keyUsage);
- Assert.True(keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.KeyCertSign));
- Assert.True(keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.CrlSign));
-
- // Deliberately NO EKU on the CA: a CA carrying an EKU makes chain validation of its
- // leaves fail with NotValidForUsage on Windows.
- Assert.Null(authority.Extensions["2.5.29.37"]);
- Assert.Equal("CN=XProtect WebContent Plugin Loopback Proxy", authority.Subject);
+ _tls.Dispose();
+
+ await Assert.ThrowsAsync(() => LeafAsync("cam1.test"));
}
public void Dispose()
{
- _tls.Dispose();
+ _tls?.Dispose();
+ _authority.Dispose();
}
}
}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 7e0c90e..95f1e14 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -36,7 +36,9 @@
+
+
diff --git a/src/WebContent/Client/LoopbackProxyListener.cs b/src/WebContent/Client/LoopbackProxyListener.cs
index 2a817de..33f6469 100644
--- a/src/WebContent/Client/LoopbackProxyListener.cs
+++ b/src/WebContent/Client/LoopbackProxyListener.cs
@@ -17,12 +17,11 @@ namespace WebContent.Client
/// Started when a proxied browser is created and stopped when that browser is disposed;
/// WebView2 environment recreation reuses the same listener and port.
///
- /// Ticket #56 (ADR-0002): while the listener serves, it also terminates allowlisted https
- /// CONNECT tunnels itself — a session CA plus per-host leaves
- /// wrap each tunnel in TLS at loopback, and the decrypted requests continue through the same
- /// fetch pipeline. The session CA is trusted in the user's root store only for the listener's
- /// lifetime (and only when https targets exist); a listener without https targets never mints
- /// a CA at all.
+ /// Ticket #56 (ADR-0002/ADR-0003): while the listener serves, allowlisted https CONNECT
+ /// tunnels terminate at , which asks the Event Server to sign
+ /// locally generated keys and wraps each tunnel in TLS at loopback; the decrypted requests
+ /// continue through the same fetch pipeline. The listener itself never touches a certificate
+ /// store - trust of the deployment CA is a machine-level deployment concern, not session state.
///
internal sealed class LoopbackProxyListener
{
@@ -31,7 +30,6 @@ internal sealed class LoopbackProxyListener
private IReadOnlyList _canonicalTargets;
private TcpListener _listener;
private volatile bool _stopping;
- private bool _trustInstalled;
internal int Port { get; private set; }
@@ -39,20 +37,18 @@ internal LoopbackProxyListener(IReadOnlyList canonicalTargets, Func EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), message));
+ _tls = new ProxyLoopbackTls(RequestLeafAsync, message => EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), message));
}
/// Swap the allowlist gate live (an admin target edit needs no browser restart).
internal void UpdateTargets(IReadOnlyList canonicalTargets)
{
_canonicalTargets = canonicalTargets ?? new string[0];
- ReconcileTrust();
}
/// Bind 127.0.0.1:0 and start accepting. The assigned port is available after.
internal void Start()
{
- ReconcileTrust();
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
@@ -71,42 +67,29 @@ internal void Stop()
EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port} stopped with: {exception.Message}");
}
- // The session CA never outlives the listener: untrust first (browser sessions on it are
- // over), then drop the leaves and CA keys.
- if (_trustInstalled)
- {
- _trustInstalled = false;
- ProxyLoopbackTrust.Remove(_tls.AuthorityCertificate);
- }
-
+ // Leaf certificates and their local key containers die with the listener; the
+ // deployment CA is shared machine state and deliberately untouched here.
_tls.Dispose();
}
///
- /// The user must trust the session CA exactly while https targets could be tunnelled:
- /// install when any https target is allowlisted, remove when none is. Idempotent.
+ /// The ADR-0003 mint channel: ask the Event Server (over the same messaging client the
+ /// fetches use) to sign this locally generated public key for the given origin.
///
- private void ReconcileTrust()
+ private static Task RequestLeafAsync(string host, int port, byte[] subjectPublicKeyInfoDer)
{
- var needsTrust = false;
- foreach (var target in _canonicalTargets)
+ var client = ProxyFetchClient.Active;
+ if (client == null)
{
- if (target != null && target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ return Task.FromResult(new ProxyLeafResponsePayload
{
- needsTrust = true;
- break;
- }
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy fetch client is not connected to the Event Server.",
+ });
}
- if (needsTrust && !_trustInstalled)
- {
- _trustInstalled = ProxyLoopbackTrust.Install(_tls.AuthorityCertificate);
- }
- else if (!needsTrust && _trustInstalled)
- {
- _trustInstalled = false;
- ProxyLoopbackTrust.Remove(_tls.AuthorityCertificate);
- }
+ return client.RequestLeafAsync(host, port, subjectPublicKeyInfoDer);
}
private async Task AcceptLoopAsync()
diff --git a/src/WebContent/Client/ProxyLoopbackTrust.cs b/src/WebContent/Client/ProxyLoopbackTrust.cs
index 15bd586..de86126 100644
--- a/src/WebContent/Client/ProxyLoopbackTrust.cs
+++ b/src/WebContent/Client/ProxyLoopbackTrust.cs
@@ -1,92 +1,51 @@
using System;
using System.Security.Cryptography.X509Certificates;
-using VideoOS.Platform;
namespace WebContent.Client
{
///
- /// Trusted-root store management for the session CA behind proxied https tunnels (#56,
- /// ADR-0002). The CA - and therefore every leaf the loopback listener presents - is trusted
- /// only while the proxied browser lives: installed when a listener with https targets starts,
- /// removed when it stops. Nothing about it survives the browser session.
- ///
- /// Windows shows its root-store consent prompt for programmatic installs into the user's
- /// trusted roots; a declined prompt is a logged failure, not a crash - the browser then shows
- /// its own certificate warning (still fail-closed), which is the acceptable degraded path.
+ /// Read-only trust posture for the deployment CA behind proxied https tunnels (#56,
+ /// ADR-0003). The CA is trusted by a one-time elevated import into the machine's trusted-root
+ /// store - never by this plugin: a non-elevated Smart Client must not (and crypt32 will not
+ /// silently) write trust stores. When the CA is absent the tunnels still serve and the
+ /// browser shows its own certificate error; the caller's log carries the exact import step.
///
internal static class ProxyLoopbackTrust
{
- internal static bool Install(X509Certificate2 authorityCertificate)
+ ///
+ /// True when a certificate with this thumbprint sits in a trusted-root store the browser
+ /// consults: LocalMachine\Root first (the documented deployment path), CurrentUser\Root
+ /// second (covers a legacy or manually per-user import).
+ ///
+ internal static bool IsRootTrusted(string thumbprint, Action log)
{
- if (authorityCertificate == null)
+ if (string.IsNullOrEmpty(thumbprint))
{
return false;
}
- try
- {
- using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
- {
- store.Open(OpenFlags.ReadWrite);
- var existing = store.Certificates.Find(
- X509FindType.FindByThumbprint, authorityCertificate.Thumbprint, false);
- if (existing.Count > 0)
- {
- return true;
- }
-
- store.Add(authorityCertificate);
- }
-
- EnvironmentManager.Instance.Log(
- false,
- nameof(ProxyLoopbackTrust),
- $"Loopback proxy session CA '{authorityCertificate.Subject}' installed in the user's trusted roots for this browser session.");
- return true;
- }
- catch (Exception exception)
- {
- EnvironmentManager.Instance.Log(
- true,
- nameof(ProxyLoopbackTrust),
- $"Loopback proxy session CA install failed ({exception.Message}); https tunnels will show a browser certificate warning until it succeeds.");
- return false;
- }
+ return FindInRoot(thumbprint, StoreLocation.LocalMachine, log) || FindInRoot(thumbprint, StoreLocation.CurrentUser, log);
}
- internal static void Remove(X509Certificate2 authorityCertificate)
+ private static bool FindInRoot(string thumbprint, StoreLocation location, Action log)
{
- if (authorityCertificate == null)
- {
- return;
- }
-
try
{
- using (var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser))
+ using (var store = new X509Store(StoreName.Root, location))
{
- store.Open(OpenFlags.ReadWrite);
- var found = store.Certificates.Find(
- X509FindType.FindByThumbprint, authorityCertificate.Thumbprint, false);
- foreach (var certificate in found)
- {
- store.Remove(certificate);
- }
+ store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
+ return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false).Count > 0;
}
-
- EnvironmentManager.Instance.Log(
- false,
- nameof(ProxyLoopbackTrust),
- $"Loopback proxy session CA '{authorityCertificate.Subject}' removed from the user's trusted roots.");
}
catch (Exception exception)
{
- EnvironmentManager.Instance.Log(
- true,
- nameof(ProxyLoopbackTrust),
- $"Loopback proxy session CA removal failed ({exception.Message}).");
+ if (log != null)
+ {
+ log($"WebContent proxy: reading the {location} trusted-root store failed: {exception.Message}");
+ }
+
+ return false;
}
}
}
}
-
diff --git a/src/WebContent/ProxyFetchClient.cs b/src/WebContent/ProxyFetchClient.cs
index 4227221..c88932c 100644
--- a/src/WebContent/ProxyFetchClient.cs
+++ b/src/WebContent/ProxyFetchClient.cs
@@ -27,8 +27,10 @@ internal sealed class ProxyFetchClient
private ServerId _serverId;
private MessageCommunication _communication;
private object _responseFilterRegistration;
+ private object _leafResponseFilterRegistration;
private object _echoFilterRegistration;
private ProxyFetchCorrelation _correlation;
+ private ProxyLeafCorrelation _leafCorrelation;
private bool _trace;
internal bool IsReady => _communication != null;
@@ -61,10 +63,17 @@ internal void Setup(ServerId masterSite)
_serverId = masterSite;
_communication = MessageCommunicationManager.Get(masterSite);
_correlation = new ProxyFetchCorrelation();
+ _leafCorrelation = new ProxyLeafCorrelation();
_responseFilterRegistration = _communication.RegisterCommunicationFilter(
OnProxyFetchResponse,
new CommunicationIdFilter(ProxyMessageIds.FetchResponseId));
+ // ADR-0003: the same client hosts the leaf-mint exchange the loopback listener's
+ // TLS termination needs; its single-frame responses correlate separately.
+ _leafResponseFilterRegistration = _communication.RegisterCommunicationFilter(
+ OnProxyLeafResponse,
+ new CommunicationIdFilter(ProxyMessageIds.LeafResponseId));
+
// Diagnostic (trace only): our own requests are filter-routed back to the sender's
// endpoint queue. Inspecting that echo tests whether THIS process deserializes our
// payload types at all: an intact echo isolates payload stripping to the response
@@ -91,7 +100,7 @@ internal void Close()
var communication = _communication;
if (communication != null)
{
- foreach (var registration in new[] { _responseFilterRegistration, _echoFilterRegistration })
+ foreach (var registration in new[] { _responseFilterRegistration, _leafResponseFilterRegistration, _echoFilterRegistration })
{
if (registration == null)
{
@@ -110,9 +119,11 @@ internal void Close()
}
_responseFilterRegistration = null;
+ _leafResponseFilterRegistration = null;
_echoFilterRegistration = null;
_communication = null;
_correlation = null;
+ _leafCorrelation = null;
// Deliberately no MessageCommunicationManager.Stop: the messaging session is
// process-wide and may be shared by other plugins (no MIP sample ever stops it).
@@ -191,6 +202,83 @@ internal Task FetchAsync(ProxyFetchQuery query, TimeSpan? dead
return completion;
}
+ ///
+ /// ADR-0003: send one loopback-leaf mint request over the shared channel and return the
+ /// task the single response frame completes. The leaf exchange is far less frequent than
+ /// fetches (one per origin per browser session), so it shares the fetcher's messaging
+ /// session and JSON-string discipline rather than owning a separate connection.
+ ///
+ internal Task RequestLeafAsync(string host, int port, byte[] subjectPublicKeyInfo, TimeSpan? deadline = null)
+ {
+ var correlation = _leafCorrelation;
+ var communication = _communication;
+ if (communication == null || correlation == null)
+ {
+ return Task.FromResult(new ProxyLeafResponsePayload
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy channel is not connected to the Event Server.",
+ });
+ }
+
+ var correlationId = Guid.NewGuid().ToString("N");
+ var completion = correlation.Begin(correlationId, deadline ?? DefaultDeadline);
+
+ try
+ {
+ communication.TransmitMessage(
+ new Message(
+ ProxyMessageIds.LeafRequestId,
+ ProxyFetchWireCodec.ToWire(new ProxyLeafRequestPayload
+ {
+ CorrelationId = correlationId,
+ Host = host,
+ Port = port,
+ SpkiBase64 = Convert.ToBase64String(subjectPublicKeyInfo),
+ })),
+ null,
+ null,
+ null);
+ }
+ catch (Exception exception)
+ {
+ correlation.Abandon(correlationId);
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy leaf request not sent: {exception.Message}");
+ }
+
+ return completion;
+ }
+
+ private object OnProxyLeafResponse(Message message, FQID destination, FQID source)
+ {
+ var response = ProxyFetchWireCodec.LeafResponseFromWire(message?.Data);
+ if (response != null)
+ {
+ var correlation = _leafCorrelation;
+ if (correlation != null && !correlation.Feed(response) && _trace)
+ {
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyFetchClient),
+ $"Leaf response unmatched: correlationId='{response.CorrelationId}'.");
+ }
+
+ return null;
+ }
+
+ if (_trace && message != null)
+ {
+ var actual = message.Data == null ? "null Data" : message.Data.GetType().FullName;
+ EnvironmentManager.Instance.Log(
+ false,
+ nameof(ProxyFetchClient),
+ $"Leaf response not recognized: {actual} destination={destination} source={source}");
+ }
+
+ return null;
+ }
+
private object OnProxyFetchResponse(Message message, FQID destination, FQID source)
{
var frame = ProxyFetchWireCodec.FrameFromWire(message?.Data);
diff --git a/src/WebContent/ProxyLoopbackAuthority.cs b/src/WebContent/ProxyLoopbackAuthority.cs
index 8e8fea1..128dfdd 100644
--- a/src/WebContent/ProxyLoopbackAuthority.cs
+++ b/src/WebContent/ProxyLoopbackAuthority.cs
@@ -683,15 +683,16 @@ public void Dispose()
#endif
///
- /// Ownership bundle for one minted leaf: the usable certificate plus the key object that must
- /// outlive it (the Framework PrivateKey attachment references the provider handle/container).
+ /// Ownership bundle for one minted leaf: the usable certificate plus the key owner that must
+ /// outlive it (the Framework PrivateKey attachment references the provider handle/container,
+ /// and a client-generated key is wrapped in ).
///
internal sealed class ProxyLoopbackLeafOwner : IDisposable
{
private readonly X509Certificate2 _certificate;
- private readonly RSA _key;
+ private readonly IDisposable _key;
- internal ProxyLoopbackLeafOwner(X509Certificate2 certificate, RSA key)
+ internal ProxyLoopbackLeafOwner(X509Certificate2 certificate, IDisposable key)
{
_certificate = certificate;
_key = key;
diff --git a/src/WebContent/ProxyLoopbackClientKey.cs b/src/WebContent/ProxyLoopbackClientKey.cs
new file mode 100644
index 0000000..b26dd46
--- /dev/null
+++ b/src/WebContent/ProxyLoopbackClientKey.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace WebContent
+{
+ ///
+ /// Ticket #56 (ADR-0003): the client-side key behind one loopback leaf certificate. The key
+ /// pair is generated where the TLS listener runs (the Smart Client machine) and only the
+ /// SubjectPublicKeyInfo is handed to the Event Server for signing - the private half never
+ /// leaves this process. binds the minted certificate DER to the
+ /// local key in a form Schannel accepts for AuthenticateAsServer: a persisted CAPI exchange
+ /// container on .NET Framework, a PFX roundtrip on modern .NET (both recipes are the ones
+ /// the certificate probes proved; ephemeral in-memory keys fail server auth).
+ ///
+ internal sealed class ProxyLoopbackClientKey : IDisposable
+ {
+#if NETFRAMEWORK
+ private const int KeyBits = 2048;
+ private const string EnhancedProviderName = "Microsoft Enhanced RSA and AES Cryptographic Provider";
+
+ private readonly RSACryptoServiceProvider _key;
+
+ internal static ProxyLoopbackClientKey Create()
+ {
+ // AT_KEYEXCHANGE on the enhanced provider: Schannel rejects signature-usage CAPI keys
+ // for server authentication. The container outlives nothing - PersistKeyInCsp=false
+ // deletes it when this object is disposed, matching the per-session leaf lifetime.
+ var parameters = new CspParameters
+ {
+ KeyContainerName = "WCProxyLeaf-" + Guid.NewGuid().ToString("N").Substring(0, 12),
+ ProviderName = EnhancedProviderName,
+ ProviderType = 24, // PROV_RSA_AES
+ KeyNumber = (int)KeyNumber.Exchange,
+ };
+ var key = new RSACryptoServiceProvider(KeyBits, parameters);
+ key.PersistKeyInCsp = false;
+ return new ProxyLoopbackClientKey(key, Der.SubjectPublicKeyInfo(key));
+ }
+
+ private ProxyLoopbackClientKey(RSACryptoServiceProvider key, byte[] subjectPublicKeyInfoDer)
+ {
+ _key = key;
+ SubjectPublicKeyInfoDer = subjectPublicKeyInfoDer;
+ }
+
+ internal X509Certificate2 AttachLeaf(byte[] leafCertificateDer)
+ {
+ var certificate = new X509Certificate2(leafCertificateDer ?? throw new ArgumentNullException(nameof(leafCertificateDer)));
+ certificate.PrivateKey = _key; // Framework-only attachment; keeps _key alive via the owner bundle
+ return certificate;
+ }
+
+ public void Dispose()
+ {
+ try { _key.Dispose(); } catch { }
+ }
+#else
+ private readonly RSA _key;
+
+ internal static ProxyLoopbackClientKey Create()
+ {
+ var key = RSA.Create(2048);
+ return new ProxyLoopbackClientKey(key, key.ExportSubjectPublicKeyInfo());
+ }
+
+ private ProxyLoopbackClientKey(RSA key, byte[] subjectPublicKeyInfoDer)
+ {
+ _key = key;
+ SubjectPublicKeyInfoDer = subjectPublicKeyInfoDer;
+ }
+
+ internal X509Certificate2 AttachLeaf(byte[] leafCertificateDer)
+ {
+ var certificate = X509CertificateLoader.LoadCertificate(
+ leafCertificateDer ?? throw new ArgumentNullException(nameof(leafCertificateDer)));
+ var withKey = certificate.CopyWithPrivateKey(_key);
+
+ // Schannel refuses ephemeral CNG keys for server authentication; the PFX roundtrip
+ // persists the key material into a form it accepts (same recipe as the minting side).
+ var password = Guid.NewGuid().ToString("N");
+ var portable = withKey.Export(X509ContentType.Pkcs12, password);
+ var usable = X509CertificateLoader.LoadPkcs12(portable, password);
+ withKey.Dispose();
+ certificate.Dispose();
+ return usable;
+ }
+
+ public void Dispose()
+ {
+ try { _key.Dispose(); } catch { }
+ }
+#endif
+
+ /// The public half safe to send to the Event Server for minting.
+ internal byte[] SubjectPublicKeyInfoDer { get; }
+ }
+}
diff --git a/src/WebContent/ProxyLoopbackTls.cs b/src/WebContent/ProxyLoopbackTls.cs
index e4f4a71..c698b15 100644
--- a/src/WebContent/ProxyLoopbackTls.cs
+++ b/src/WebContent/ProxyLoopbackTls.cs
@@ -1,91 +1,139 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Net;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
-using System.Text;
using System.Threading.Tasks;
+using WebContent.Client;
namespace WebContent
{
///
- /// Ticket #56 (ADR-0002): mints and owns the certificates that let the loopback listener
- /// terminate a browser's CONNECT tunnel for an allowlisted https target.
+ /// Asks the Event Server to mint a loopback leaf for one origin, carrying the locally
+ /// generated SubjectPublicKeyInfo (ADR-0003). Implemented over MIP messaging by
+ /// ; injectable for tests.
+ ///
+ internal delegate Task ProxyLeafMintChannel(string host, int port, byte[] subjectPublicKeyInfoDer);
+
+ ///
+ /// Ticket #56 (ADR-0003): owns the certificates that let the loopback listener terminate a
+ /// browser's CONNECT tunnel for an allowlisted https target.
///
- /// Shape: one ephemeral session CA (the authority) plus per-host leaf certificates signed by
- /// it. The authority is installed into the user's trusted-root store for the lifetime of the
- /// proxied browser session (that install lives with the listener/browser lifecycle, not here),
- /// so the chain Chromium sees is leaf -> authority, validated with full chain semantics.
+ /// Shape: the signing authority lives on the Event Server (its private key never reaches this
+ /// machine); per origin this process generates an exchange key pair, sends only the public
+ /// half over the mint channel, and binds the returned certificate DER to the local key. The
+ /// chain the browser validates is leaf -> deployment CA, whose certificate the operator has
+ /// imported into the machine's trusted-root store.
///
- /// Everything is in-memory and per-process: nothing about these certificates outlives the
- /// session, and a fresh authority is minted every session. Leaf private keys are kept in a
- /// form Schannel accepts for server authentication (persisted CAPI containers on .NET
- /// Framework; a PFX roundtrip on modern .NET) - an ephemeral in-memory key fails
- /// AuthenticateAsServer with "the platform does not support ephemeral keys".
+ /// Leaves are cached per (host, port) for the browser session; key containers and certificates
+ /// are disposed with the manager. When a mint succeeds but the deployment CA is missing from
+ /// the trust stores, the tunnels still serve - the browser shows its own certificate error
+ /// (fail-closed) and the log names the one-time elevated import that fixes it.
///
internal sealed class ProxyLoopbackTls : IDisposable
{
- /// Common name of the session authority; pinned by tests and by the trust-store install.
+ /// Common name of the deployment CA; matches what the Event Server provisions.
internal const string AuthorityCommonName = "XProtect WebContent Plugin Loopback Proxy";
- private static readonly Oid ServerAuthEku = new Oid("1.3.6.1.5.5.7.3.1");
-
+ private readonly ProxyLeafMintChannel _mint;
private readonly Action _log;
private readonly object _gate = new object();
private readonly Dictionary _leaves =
new Dictionary(StringComparer.OrdinalIgnoreCase);
private readonly List _leafOwners = new List();
-
- private Authority _authority;
+ private readonly HashSet _authorityTrustReported = new HashSet(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
- internal ProxyLoopbackTls(Action log)
+ internal ProxyLoopbackTls(ProxyLeafMintChannel mint, Action log)
{
+ _mint = mint ?? throw new ArgumentNullException(nameof(mint));
_log = log ?? delegate { };
}
- /// The session CA. Created on first use; callers never sign with it directly.
- internal X509Certificate2 AuthorityCertificate
- {
- get
- {
- lock (_gate)
- {
- return EnsureAuthority().Certificate;
- }
- }
- }
-
///
- /// The leaf presented for tunnels to this host. Cached per host (case-insensitive): a browser
- /// reconnecting to the same target reuses the certificate instead of growing the process.
+ /// The leaf presented for tunnels to this origin. Cached per host:port (host part
+ /// case-insensitive): a browser reconnecting reuses the certificate instead of growing
+ /// the process or re-asking the Event Server.
///
- internal X509Certificate2 GetOrCreateLeafCertificate(string host)
+ internal async Task GetOrCreateLeafCertificateAsync(string host, int port)
{
if (string.IsNullOrWhiteSpace(host))
{
throw new ArgumentException("Leaf host must be non-empty.", nameof(host));
}
+ var cacheKey = host + ":" + port;
lock (_gate)
{
ThrowIfDisposed();
- X509Certificate2 leaf;
- if (_leaves.TryGetValue(host, out leaf))
+ X509Certificate2 cached;
+ if (_leaves.TryGetValue(cacheKey, out cached))
{
- return leaf;
+ return cached;
}
+ }
- var owner = EnsureAuthority().CreateLeaf(host);
- _leaves[host] = owner.Certificate;
- _leafOwners.Add(owner);
- _log("WebContent proxy: minted loopback TLS leaf for " + host);
- return owner.Certificate;
+ // Mint outside the lock: the exchange is a network roundtrip, and a duplicate mint
+ // under a race is harmless (the first insertion wins).
+ var key = ProxyLoopbackClientKey.Create();
+ ProxyLeafResponsePayload response;
+ try
+ {
+ response = await _mint(host, port, key.SubjectPublicKeyInfoDer).ConfigureAwait(false);
}
+ catch
+ {
+ key.Dispose();
+ throw;
+ }
+
+ if (response == null || !response.Success || string.IsNullOrEmpty(response.LeafDerBase64))
+ {
+ key.Dispose();
+ throw new InvalidOperationException(
+ "The Event Server refused the loopback leaf for " + cacheKey +
+ " (code=" + (response == null ? "null" : response.ErrorCode) +
+ "): " + (response == null ? "no response" : response.ErrorMessage));
+ }
+
+ X509Certificate2 leaf;
+ try
+ {
+ leaf = key.AttachLeaf(Convert.FromBase64String(response.LeafDerBase64));
+ }
+ catch
+ {
+ key.Dispose();
+ throw;
+ }
+
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ leaf.Dispose();
+ key.Dispose();
+ throw new ObjectDisposedException(nameof(ProxyLoopbackTls));
+ }
+
+ X509Certificate2 existing;
+ if (_leaves.TryGetValue(cacheKey, out existing))
+ {
+ leaf.Dispose();
+ key.Dispose();
+ return existing;
+ }
+
+ _leaves[cacheKey] = leaf;
+ _leafOwners.Add(new ProxyLoopbackLeafOwner(leaf, key));
+ }
+
+ _log("WebContent proxy: Event Server minted loopback TLS leaf for " + cacheKey);
+ ReportAuthorityTrustState(response.AuthorityThumbprint);
+ return leaf;
}
///
@@ -108,7 +156,7 @@ internal async Task AcceptTunnelAsync(Stream tunnel, byte[] seed, Uri or
throw new ArgumentException("Tunnel origin must be an https URI.", nameof(origin));
}
- var certificate = GetOrCreateLeafCertificate(origin.IdnHost);
+ var certificate = await GetOrCreateLeafCertificateAsync(origin.IdnHost, origin.Port).ConfigureAwait(false);
var source = seed != null && seed.Length > 0
? (Stream)new PrefixedStream(seed, tunnel)
@@ -148,23 +196,39 @@ public void Dispose()
}
_leafOwners.Clear();
+ }
+ }
+
+ ///
+ /// Log the machine's trust posture for this deployment CA exactly once per thumbprint:
+ /// either confirmation, or the exact one-time elevated step the operator still owes.
+ /// Reading the root stores never needs elevation; only the import does.
+ ///
+ private void ReportAuthorityTrustState(string authorityThumbprint)
+ {
+ if (string.IsNullOrEmpty(authorityThumbprint))
+ {
+ return;
+ }
- if (_authority != null)
+ lock (_gate)
+ {
+ if (!_authorityTrustReported.Add(authorityThumbprint))
{
- try { _authority.Dispose(); } catch { }
- _authority = null;
+ return;
}
}
- }
- private Authority EnsureAuthority()
- {
- if (_disposed)
+ if (ProxyLoopbackTrust.IsRootTrusted(authorityThumbprint, _log))
{
- throw new ObjectDisposedException(nameof(ProxyLoopbackTls));
+ _log("WebContent proxy: deployment CA " + authorityThumbprint + " is present in this machine's trusted roots.");
+ return;
}
- return _authority ?? (_authority = Authority.Create(_log));
+ _log(
+ "WebContent proxy: deployment CA " + authorityThumbprint +
+ " is NOT in this machine's trusted roots. Proxied https targets will show a browser certificate error until an administrator imports the CA certificate exported by the Event Server: " +
+ "Import-Certificate -FilePath -CertStoreLocation Cert:\\LocalMachine\\Root");
}
private void ThrowIfDisposed()
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index d258594..40b17a3 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -113,6 +113,7 @@
+
From 56030ba0c1ded272a4011e59e9c906e9c68597da Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 14:56:29 -0700
Subject: [PATCH 026/187] test(ux): #56 SC-2 TLS rows - LoginTestServer gains a
self-signed HTTPS mode (in-process cert, SAN for the VMS-network address, TLS
journal entries as probe data) and the login probe splits into fail-closed
(proxy error page + per-target guidance, refused handshake journaled
server-side) and accept-untrusted (full login/session/XHR through the tunnel)
runner-shared facts
---
.../Harness/LoginTestServer.cs | 99 +++++++-
.../ScProxyLoginProbeTests.cs | 231 ++++++++++++++----
2 files changed, 278 insertions(+), 52 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
index 244e7b6..3817e40 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs
@@ -1,5 +1,8 @@
using System.Net;
using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace WebContent.UxTests.Harness;
@@ -14,6 +17,10 @@ namespace WebContent.UxTests.Harness;
/// issued with a 302, a dashboard that only answers when the cookie comes back, and an XHR
/// endpoint the dashboard polls in the background. Every request is journaled so probe
/// artifacts show what actually crossed the proxy.
+///
+/// #56 adds a TLS mode: the same site behind a self-signed certificate minted in-process, so
+/// the Event Server's target TLS validation (and the per-target accept-untrusted override)
+/// has a genuinely untrusted chain to decide on.
///
public sealed class LoginTestServer : IDisposable
{
@@ -21,6 +28,7 @@ public sealed class LoginTestServer : IDisposable
public const string Password = "camera1";
private readonly TcpListener _listener;
+ private readonly X509Certificate2 _tlsCertificate;
private readonly HashSet _issuedSessions = new();
private readonly List _journal = new();
@@ -31,15 +39,68 @@ public sealed class LoginTestServer : IDisposable
public IReadOnlyList Journal { get { lock (_journal) return _journal.ToList(); } }
- public LoginTestServer(int port)
+ public LoginTestServer(int port) : this(port, useTls: false)
+ {
+ }
+
+ /// Serve over HTTPS with an in-process self-signed certificate —
+ /// untrusted by design, which is exactly what the #56 fail-closed / override probes need.
+ /// The certificate carries the bind host as SAN so the failure is chain-trust, not name mismatch.
+ public LoginTestServer(int port, bool useTls)
{
Port = port;
+ if (useTls)
+ {
+ _tlsCertificate = CreateSelfSignedCertificate();
+ }
+
_listener = new TcpListener(IPAddress.Any, port);
_listener.Start();
_ = Task.Run(AcceptLoopAsync);
}
- public void Dispose() => _listener.Stop();
+ public void Dispose()
+ {
+ _listener.Stop();
+ _tlsCertificate?.Dispose();
+ }
+
+ ///
+ /// RSA self-signed certificate valid for the addresses the probe binds (the VMS-network
+ /// address the allowlist names, plus loopback for local poking). KeyUsage/EKU mirror a real
+ /// camera portal's server certificate; only the chain is untrusted.
+ ///
+ private static X509Certificate2 CreateSelfSignedCertificate()
+ {
+ using var key = RSA.Create(2048);
+ var request = new CertificateRequest(
+ "CN=ACME IPCStore (self-signed test)",
+ key,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
+ false));
+ request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
+ new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
+ false));
+
+ var san = new SubjectAlternativeNameBuilder();
+ san.AddIpAddress(IPAddress.Loopback);
+ san.AddIpAddress(IPAddress.Parse("172.16.128.210"));
+ san.AddDnsName(Environment.MachineName.ToLowerInvariant());
+ request.CertificateExtensions.Add(san.Build());
+
+ using var created = request.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddDays(365));
+
+ // PFX roundtrip lands the key in a persisted form Schannel accepts server-side.
+ var password = Guid.NewGuid().ToString("N");
+ var portable = created.Export(X509ContentType.Pkcs12, password);
+ return X509CertificateLoader.LoadPkcs12(portable, password);
+ }
private async Task AcceptLoopAsync()
{
@@ -64,9 +125,31 @@ private async Task ServeAsync(TcpClient client)
using (client)
{
client.NoDelay = true;
- using var stream = client.GetStream();
+ Stream stream = client.GetStream();
try
{
+ if (_tlsCertificate != null)
+ {
+ var tls = new System.Net.Security.SslStream(stream, false);
+ stream = tls;
+ try
+ {
+ await tls.AuthenticateAsServerAsync(
+ _tlsCertificate, false, SslProtocols.Tls12, false);
+ }
+ catch (Exception handshake)
+ {
+ // A failed client handshake is itself probe data (e.g. the fetcher
+ // refusing the chain mid-TLS is a clean close, not a request).
+ lock (_journal)
+ {
+ _journal.Add($"TLS handshake failed: {handshake.GetType().Name}: {handshake.Message}");
+ }
+
+ return;
+ }
+ }
+
await HandleAsync(stream);
}
catch (Exception exception)
@@ -76,10 +159,14 @@ private async Task ServeAsync(TcpClient client)
_journal.Add($"request aborted: {exception.GetType().Name}: {exception.Message}");
}
}
+ finally
+ {
+ stream.Dispose();
+ }
}
}
- private async Task HandleAsync(NetworkStream stream)
+ private async Task HandleAsync(Stream stream)
{
var head = await ReadHeadAsync(stream);
if (head == null)
@@ -277,7 +364,7 @@ private static byte[] Plain(int status, string text)
return Encoding.UTF8.GetBytes(head.ToString()).Concat(body).ToArray();
}
- private static async Task ReadHeadAsync(NetworkStream stream)
+ private static async Task ReadHeadAsync(Stream stream)
{
var received = new MemoryStream();
var buffer = new byte[4096];
@@ -303,7 +390,7 @@ private static async Task ReadHeadAsync(NetworkStream stream)
return null;
}
- private static async Task WriteAsync(NetworkStream stream, byte[] response)
+ private static async Task WriteAsync(Stream stream, byte[] response)
{
await stream.WriteAsync(response, 0, response.Length);
await stream.FlushAsync();
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 7fd6231..c24153e 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -7,7 +7,7 @@
namespace WebContent.UxTests;
///
-/// Live acceptance probe for #55 (SC-2 in docs/verification-matrix.md): a vendor-style login
+/// Live acceptance probes for #55 (SC-2 in docs/verification-matrix.md): a vendor-style login
/// flow through the proxied workspace browser. A POST login form, a 302 + Set-Cookie session
/// round-trip, a cookie-gated dashboard, and a background XHR all cross the
/// browser -> loopback listener -> MIP messaging -> Event Server fetcher chain with their
@@ -16,6 +16,15 @@ namespace WebContent.UxTests;
/// site from this machine via (the Event Server fetches it from
/// the server network), then drives the login in Smart Client over CDP.
/// Artifacts land in webcontent-sc-proxy-probe\<run> (sc-login-probe-*.png / .txt).
+///
+/// #56 adds the two HTTPS rows: the same site behind a self-signed certificate, allowlisted
+/// WITHOUT the per-target override (the Event Server must refuse the target chain and the
+/// browser must show the proxy error page — fail-closed), and allowlisted WITH
+/// "accept-untrusted" (the whole login flow must complete over the loopback-terminated tunnel).
+/// Both rows presuppose the ADR-0003 deployment: the Event Server holds the deployment CA in
+/// LocalMachine\Personal and this machine trusts it in LocalMachine\Root, so the browser's
+/// tunnel TLS to the loopback listener validates cleanly and any visible failure is the
+/// TARGET's certificate decision, not the loopback leaf's.
///
public class ScProxyLoginProbeTests
{
@@ -26,6 +35,12 @@ public class ScProxyLoginProbeTests
///
private const string DefaultLoginTarget = "http://172.16.128.210:18123";
+ ///
+ /// #56: the self-signed HTTPS origin for the two TLS rows (its own port, so its allowlist
+ /// entry never collides with the plain-HTTP login origin).
+ ///
+ private const string DefaultHttpsLoginTarget = "https://172.16.128.210:18124";
+
private readonly ITestOutputHelper _output;
public ScProxyLoginProbeTests(ITestOutputHelper output) => _output = output;
@@ -33,19 +48,115 @@ public class ScProxyLoginProbeTests
[Fact]
[Trait("Category", "probe")]
public async Task ProbeProxiedVendorLoginFlow()
+ {
+ var target = Environment.GetEnvironmentVariable("UX_LOGIN_TARGET") ?? DefaultLoginTarget;
+
+ var outcome = await RunLoginProbeAsync(
+ allowlistEntry: target,
+ target: target,
+ useTls: false,
+ artifactPrefix: "sc-login-probe");
+
+ // The probe goes RED like SC-1: a login that loops back to the form, a proxy error page,
+ // or a background XHR that comes back denied all fail the run, not merely record it.
+ Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}");
+ Assert.NotNull(outcome.Title);
+ Assert.False(titleIsProxyError(outcome.Title), $"browser showed the proxy error page instead of the vendor site: '{outcome.Title}'");
+ Assert.Equal("SIGNED IN", outcome.SignedIn);
+ Assert.NotNull(outcome.XhrStatus);
+ Assert.StartsWith("XHR-OK", outcome.XhrStatus);
+ Assert.Contains("POST /login", outcome.Log);
+ }
+
+ [Fact]
+ [Trait("Category", "probe")]
+ public async Task ProbeProxiedHttpsTargetFailsClosedWithoutOverride()
+ {
+ var target = Environment.GetEnvironmentVariable("UX_HTTPS_LOGIN_TARGET") ?? DefaultHttpsLoginTarget;
+
+ // Allowlisted WITHOUT the accept-untrusted flag: the Event Server fetcher must refuse
+ // the self-signed target chain (TlsValidation), and the browser must see the proxy
+ // error page - never the login form, never a silent pass-through.
+ var outcome = await RunLoginProbeAsync(
+ allowlistEntry: target,
+ target: target,
+ useTls: true,
+ artifactPrefix: "sc-login-probe-tls-closed");
+
+ Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}");
+ Assert.True(
+ titleIsProxyError(outcome.Title),
+ $"fail-closed expected the proxy error page for the untrusted target, got title '{outcome.Title}'{Environment.NewLine}{outcome.Log}");
+ Assert.Contains("accept untrusted", outcome.PageBody ?? string.Empty);
+ Assert.True(
+ outcome.ServerJournal.Any(entry => entry.Contains("TLS", StringComparison.OrdinalIgnoreCase)),
+ $"the login server never recorded the refused TLS attempt{Environment.NewLine}{outcome.Log}");
+ }
+
+ [Fact]
+ [Trait("Category", "probe")]
+ public async Task ProbeProxiedHttpsLoginFlowWithAcceptUntrustedOverride()
+ {
+ var target = Environment.GetEnvironmentVariable("UX_HTTPS_LOGIN_TARGET") ?? DefaultHttpsLoginTarget;
+
+ // The same target WITH the per-entry override: the fetcher accepts the untrusted chain
+ // for exactly this origin, and the full login/session/XHR flow completes through the
+ // loopback-terminated tunnel.
+ var entry = $"{target} {AcceptUntrustedToken}";
+ var outcome = await RunLoginProbeAsync(
+ allowlistEntry: entry,
+ target: target,
+ useTls: true,
+ artifactPrefix: "sc-login-probe-tls-open");
+
+ Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}");
+ Assert.NotNull(outcome.Title);
+ Assert.False(titleIsProxyError(outcome.Title), $"override did not open the tunnel for the untrusted target: '{outcome.Title}'");
+ Assert.Equal("SIGNED IN", outcome.SignedIn);
+ Assert.NotNull(outcome.XhrStatus);
+ Assert.StartsWith("XHR-OK", outcome.XhrStatus);
+ Assert.Contains("POST /login", outcome.Log);
+ }
+
+ /// ProxyPolicy.AcceptUntrustedFlag, inlined (the harness cannot see internals).
+ private const string AcceptUntrustedToken = "accept-untrusted";
+
+ private static bool titleIsProxyError(string title) =>
+ title != null && title.StartsWith("Web Content proxy:", StringComparison.OrdinalIgnoreCase);
+
+ private sealed class ProbeOutcome
+ {
+ public string Failure;
+ public string Title;
+ public string PageBody;
+ public string SignedIn;
+ public string XhrStatus;
+ public string CookieEcho;
+ public IReadOnlyList ServerJournal = new string[0];
+ public string Log = string.Empty;
+ }
+
+ ///
+ /// Shared probe run: MC allowlist phase (the entry replaces any older line for the same
+ /// origin, so the fail-closed and override rows cannot leave contradictory entries), then
+ /// Smart Client + CDP drive. Rows that expect the proxy error page pass no login
+ /// interaction beyond navigation: the form never appears there, so the run captures the
+ /// error document and returns.
+ ///
+ private async Task RunLoginProbeAsync(
+ string allowlistEntry,
+ string target,
+ bool useTls,
+ string artifactPrefix)
{
var config = UxConfig.Load();
Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe"));
_output.WriteLine($"Artifacts: {Artifacts.RunDirectory}");
- var target = Environment.GetEnvironmentVariable("UX_LOGIN_TARGET") ?? DefaultLoginTarget;
var targetUri = new Uri(target);
var log = new System.Text.StringBuilder();
+ var outcome = new ProbeOutcome();
string failure = null;
- string title = null;
- string signedIn = null;
- string xhrStatus = null;
- string cookieEcho = null;
LoginTestServer server = null;
try
@@ -53,12 +164,12 @@ public async Task ProbeProxiedVendorLoginFlow()
// Phase 1: the workspace proxy allowlist must carry the login origin before the
// Smart Client logs in — a target the fetcher refuses is a NotAllowed frame, not a
// login page.
- EnsureAllowlistedTarget(config, target, log);
+ EnsureAllowlistedTarget(config, allowlistEntry, log);
// Phase 2: serve the vendor site on THIS machine; the Event Server proves server-side
// reachability by fetching it from the VMS network while the browser never goes direct.
- server = new LoginTestServer(targetUri.Port);
- log.AppendLine($"login server bound to port {targetUri.Port}, origin {target}");
+ server = new LoginTestServer(targetUri.Port, useTls);
+ log.AppendLine($"{(useTls ? "https" : "http")} login server bound to port {targetUri.Port}, origin {target}, allowlist entry '{allowlistEntry}'");
// Phase 3: drive the real login flow in the proxied Smart Client browser.
CdpConnector.CleanStalePortFiles(config.WebView2DataRoot);
@@ -94,13 +205,28 @@ public async Task ProbeProxiedVendorLoginFlow()
var page = await CdpConnector.WaitForPageAsync(browser, targetUri.Host);
var form = await page.WaitForSelectorAsync("#signin", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 });
+
+ outcome.Title = await page.TitleAsync();
+ log.AppendLine($"first document at {target}: title='{outcome.Title}' form={form != null}");
if (form == null)
{
- throw new InvalidOperationException($"login form not rendered at {targetUri} — page shows something else");
- }
+ // No login form: either a genuine failure or the expected fail-closed error page.
+ outcome.PageBody = await page.ContentAsync();
+ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
+ {
+ Path = Artifacts.PathOf($"{artifactPrefix}-document.png"),
+ });
+
+ if (!titleIsProxyError(outcome.Title))
+ {
+ throw new InvalidOperationException($"login form not rendered at {target} — page shows something else");
+ }
- title = await page.TitleAsync();
- log.AppendLine($"login page title='{title}'");
+ // Expected fail-closed document: capture and return without login interaction.
+ outcome.ServerJournal = Snapshot(server);
+ outcome.Log = log.ToString();
+ return outcome;
+ }
await page.FillAsync("#username", LoginTestServer.Username);
await page.FillAsync("#password", LoginTestServer.Password);
@@ -114,9 +240,9 @@ public async Task ProbeProxiedVendorLoginFlow()
throw new InvalidOperationException("#signed-in never appeared — cookie/session round-trip failed");
}
- signedIn = await marker.TextContentAsync();
- title = await page.TitleAsync();
- cookieEcho = await (await page.QuerySelectorAsync("#cookie-echo"))?.TextContentAsync();
+ outcome.SignedIn = await marker.TextContentAsync();
+ outcome.Title = await page.TitleAsync();
+ outcome.CookieEcho = await (await page.QuerySelectorAsync("#cookie-echo"))?.TextContentAsync();
// The dashboard's background fetch resolves asynchronously — reading #xhr-status the
// instant the dashboard renders races it and catches the XHR-PENDING placeholder.
@@ -125,21 +251,14 @@ await page.WaitForFunctionAsync(
"() => { var el = document.getElementById('xhr-status'); return el && el.textContent && el.textContent !== 'XHR-PENDING'; }",
null,
new Microsoft.Playwright.PageWaitForFunctionOptions { Timeout = 30000 });
- xhrStatus = await (await page.QuerySelectorAsync("#xhr-status"))?.TextContentAsync();
+ outcome.XhrStatus = await (await page.QuerySelectorAsync("#xhr-status"))?.TextContentAsync();
await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
{
- Path = Artifacts.PathOf("sc-login-probe-dashboard.png"),
+ Path = Artifacts.PathOf($"{artifactPrefix}-dashboard.png"),
});
- log.AppendLine($"after login: title='{title}' signedIn='{signedIn}' xhr='{xhrStatus}' cookieEcho='{cookieEcho}'");
- lock (server)
- {
- foreach (var entry in server.Journal)
- {
- log.AppendLine($"server: {entry}");
- }
- }
+ log.AppendLine($"after login: title='{outcome.Title}' signedIn='{outcome.SignedIn}' xhr='{outcome.XhrStatus}' cookieEcho='{outcome.CookieEcho}'");
}
catch (Exception ex)
{
@@ -148,30 +267,45 @@ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
}
finally
{
+ outcome.Failure = failure;
+ outcome.ServerJournal = Snapshot(server);
+ foreach (var entry in outcome.ServerJournal)
+ {
+ log.AppendLine($"server: {entry}");
+ }
+
server?.Dispose();
- Artifacts.WriteText("sc-login-probe-results.txt", log.ToString());
- _output.WriteLine(log.ToString());
+ outcome.Log = log.ToString();
+ Artifacts.WriteText($"{artifactPrefix}-results.txt", outcome.Log);
+ _output.WriteLine(outcome.Log);
ProcessKiller.KillClients("Client");
ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
}
- // The probe goes RED like SC-1: a login that loops back to the form, a proxy error page,
- // or a background XHR that comes back denied all fail the run, not merely record it.
- Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}");
- Assert.NotNull(title);
- Assert.False(title.StartsWith("Web Content proxy:"), $"browser showed the proxy error page instead of the vendor site: '{title}'");
- Assert.Equal("SIGNED IN", signedIn);
- Assert.NotNull(xhrStatus);
- Assert.StartsWith("XHR-OK", xhrStatus);
- Assert.Contains("POST /login", string.Join("\n", log));
+ return outcome;
+ }
+
+ private static IReadOnlyList Snapshot(LoginTestServer server)
+ {
+ if (server == null)
+ {
+ return new string[0];
+ }
+
+ lock (server)
+ {
+ return server.Journal;
+ }
}
///
- /// Management Client phase: make sure the login origin is in the McWorkspace proxy target
- /// list (proxy enabled), saving only when the list actually changed. Runs before the Smart
- /// Client so the fetched configuration carries the new allowlist.
+ /// Management Client phase: make sure the workspace proxy target list carries exactly one
+ /// line for the probe origin — the entry as given, replacing any older line for the same
+ /// origin so a previous run's flag state never leaks into this row's verdict. Saving only
+ /// when the list actually changed. Runs before the Smart Client so the fetched
+ /// configuration carries the new allowlist.
///
- private void EnsureAllowlistedTarget(UxConfig config, string target, System.Text.StringBuilder log)
+ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.StringBuilder log)
{
ProcessKiller.KillClients("VideoOS.Administration");
using var mc = ClientSession.Launch(config.AdminClientExe);
@@ -200,22 +334,27 @@ private void EnsureAllowlistedTarget(UxConfig config, string target, System.Text
.Where(line => line.Length > 0)
.ToList();
- if (lines.Any(line => string.Equals(line, target, StringComparison.OrdinalIgnoreCase)))
+ if (lines.Any(line => string.Equals(line, entry, StringComparison.OrdinalIgnoreCase)))
{
- log.AppendLine($"target '{target}' already allowlisted; no Management Client edit needed");
+ log.AppendLine($"entry '{entry}' already allowlisted; no Management Client edit needed");
return;
}
+ // Same origin with a different flag state is a contradictory entry: exactly one line
+ // decides the origin's TLS posture.
+ var originToken = entry.Split(' ')[0];
+ lines.RemoveAll(line => line.Split(' ')[0].Equals(originToken, StringComparison.OrdinalIgnoreCase));
+
ManagementClientUi.EnsureCheckBox(mcMain, "chkProxyEnabled", true);
- lines.Add(target);
+ lines.Add(entry);
ManagementClientUi.SetValue(mcMain, "txtProxyTargets", string.Join("\n", lines));
if (!ManagementClientUi.Save(mcMain, 20))
{
- throw new InvalidOperationException($"Save never became enabled after adding '{target}' to the proxy targets.");
+ throw new InvalidOperationException($"Save never became enabled after adding '{entry}' to the proxy targets.");
}
- log.AppendLine($"allowlisted '{target}' and saved");
+ log.AppendLine($"allowlisted '{entry}' and saved");
}
private static string ReadValue(AutomationElement element)
From 7c4b54f58b82ea83dfabb5404c4d4b25f6823523 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:00:31 -0700
Subject: [PATCH 027/187] docs: #56 operator runbook (one-time CA
export/Import-Certificate into LocalMachine\\Root, uninstall cleanup) +
verification-matrix SC-3 row (fail-closed / accept-untrusted live rows, unit
pins, live net472 plumbing proof recorded)
---
README.md | 25 +++++++++++++++++++++
docs/verification-matrix.md | 43 +++++++++++++++++++++++++++++++++++++
2 files changed, 68 insertions(+)
diff --git a/README.md b/README.md
index b31ebad..1611fc6 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,31 @@ get a code signing certificate setup.
The installer is very simple and has no options. Acknowledge the license, and then it will install the Microsoft Edge
WebView2 Runtime if necessary, and place the plugin files in `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin\`.
+### HTTPS targets: deployment certificate authority (one-time setup)
+
+A workspace proxy allowlist may include `https://` targets (optionally flagged
+`accept-untrusted` to tolerate a device's self-signed certificate — the flag is per target, never global).
+To make those pages render without browser certificate errors, the plugin uses one long-lived certificate
+authority per deployment (see `docs/adr/0003-event-server-mints-loopback-leaf-certs.md`):
+
+1. **The Event Server creates the CA by itself.** The plugin deployed on the Event Server creates a CA
+ certificate named `XProtect WebContent Plugin Loopback Proxy` in `LocalMachine\Personal` the first time a
+ proxied HTTPS page needs it. Its private key never leaves that machine. (If the Event Server service does
+ not run as LocalSystem, grant it write access to that store, or import a CA you provisioned yourself.)
+2. **Each Smart Client machine trusts the CA once, elevated.** Export the CA certificate from the Event
+ Server machine (`Get-ChildItem Cert:\LocalMachine\My | Where Subject -match 'Loopback Proxy' |
+ Export-Certificate -FilePath C:\webcontent-proxy-ca.cer -Type CERT`) and on every Smart Client machine run:
+
+ ```powershell
+ Import-Certificate -FilePath C:\webcontent-proxy-ca.cer -CertStoreLocation Cert:\LocalMachine\Root
+ ```
+
+ Until that import is done, proxied HTTPS pages load but show a browser certificate error — the plugin
+ never bypasses certificate validation on its own, and its log names this exact import step.
+
+To remove the plugin completely, also delete the CA: from `LocalMachine\My` on the Event Server machine
+(`Remove-Item Cert:\LocalMachine\My\`) and from `LocalMachine\Root` on each Smart Client machine.
+
### Add Web Content to a view
1. Open a new Smart Client instance (plugins are only discovered during startup) and login.
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 83ef1da..8903f66 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -35,6 +35,7 @@ is not evidence the plugin never ran.
| Proxy configuration roundtrip | #52 | `ProxyConfigurationTests` (definitions saved before the proxy existed mean proxy-off; toggle+targets survive the serialized roundtrip), `ProxyPolicyTests` (admin entries canonicalize to scheme/host/port origins; garbage rejected; exact-origin matching; duplicates/overlaps collapse), `WorkspacePropertiesTests` (JSON-blob and legacy paths emit canonical `ProxyEnabled`/`ProxyTargets`, hand-crafted target gibberish skipped) | See MC-9 below: admin toggle round-trips to the Smart Client properties page; invalid target save refused with log marker |
| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types stay plugin-namespace `[Serializable]` with public fields — wire shape itself is pinned by `ProxyFetchWireCodecTests` since #54; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; timeout), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable; #55: method/form body/Cookie+custom headers replayed at the target, 302+Set-Cookie relayed instead of followed, repeated Set-Cookie values captured separately), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged |
| Loopback forwarding proxy (Smart Client view consumes the fetch seam) | #54, write verbs #55 | `ProxyForwardCodecTests` (absolute-form proxied request parsing; origin-form/non-http refused; CONNECT recognized by authority target; outbound request-header filter keeps Cookie/Authorization/custom headers and drops hop-by-hop+Host+Content-Length; forwarded-response rendering: status line, content headers relayed, hop-by-hop dropped, Content-Length restated from the reassembled body; refusal pages), `ProxyForwardPolicyTests` (decision table via `ProxyPolicy`: allowlisted GET/HEAD/POST/PUT/DELETE/OPTIONS/PATCH forward, other origins 403, TRACE/junk verbs 405, CONNECT 502, malformed 400 — address-bar navigation follows the same rule), `ProxyForwardConnectionTests` (full stream flow: forward+relay, POST method/form body/Cookie to the fetch seam, 302+Set-Cookie relayed back, repeated Set-Cookie rendered as separate lines, 403 without touching the fetcher, oversized declared body 413, chunked body 411, Unreachable→502, Timeout→504), `ProxyBrowserArgumentsTests` (every http/https/ws scheme pinned to the listener, `<-loopback` bypass off — never direct), `ProxyWorkspaceProfileTests` (enabled+targets → listener allowlist; duplicates collapse; enabled-with-no-targets still proxies and allows nothing; disabled → no profile), `ProxyServerAllowlistTests` (ES refuses origins no proxied workspace allowlists, proxy-off workspaces don't count, closed before first resolve, stale-on-error keeps serving the last snapshot) | See SC-1 below: with the drop deployed on both the Event Server machine and the Smart Client machine, and the client's route to the target killed, the proxied page still loads — proof the traffic source is the server, not the client. See SC-2: a login POST round-trips with its session cookie in a live Smart Client session |
+| HTTPS targets: target TLS verdict, per-target override, loopback leaf mint (ADR-0002/0003) | #56 | `ProxyFetchTlsDecisionTests` (https targets get full chain validation on the Event Server; an untrusted chain refuses with `TlsValidation` unless the allowlist entry carries the per-target accept-untrusted override — never a global bypass), `ProxyPolicyTests` (target-line grammar: `https://host:port accept-untrusted` in either token order; unknown tokens or a second origin reject the whole entry), `ProxyServerAllowlistTests` (the `acceptUntrusted` flag round-trips with its entry through the workspace JSON), `ProxyLeafContractTests` (LeafRequest carries ONLY the client-generated SPKI; the allowlist union is the minting gate — `NotAllowed` fail-closed like the fetcher, `BadRequest` for malformed origin/key, `NoAuthority` when the deployment CA cannot load/create; minted leaf chains to the deployment CA with host SAN + serverAuth EKU; single-frame correlation with deadline), `ProxyLoopbackTlsTests` (leaves cached per host:port, one mint exchange per origin; refused mint fails the tunnel with the server's code; seeded `AcceptTunnelAsync` + real Schannel server handshake on a channel-minted leaf), `ProxyLoopbackClientKeyTests` (exported SPKI is public-only; `AttachLeaf` yields a private key Schannel accepts; failed attach keeps the key usable), `ProxyFetchReplyGuardTests` (leaf answers unicasted to the requester endpoint, never broadcast), `EventServerLoadGuardTests` (leaf message ids declared) | See SC-3 below: both-machine drop plus the one-time elevated CA import into `LocalMachine\Root`; a self-signed https target fails closed WITHOUT the override (proxy error page names the exact per-target switch) and the full login flow completes WITH it |
## Manual host checks
@@ -224,6 +225,45 @@ simultaneous.
with the session cookie attached (`server: GET /api/status cookie='SID=…'`, rendered as
`XHR-OK status=online cookie='SID=…'`). Full probe run ≈1 min.
+### SC-3 Proxied HTTPS targets: target-TLS verdict and machine trust (#56, ADR-0002/ADR-0003)
+
+Requires the same both-machine drop as SC-1/SC-2 (the leaf contract is new on BOTH sides: an
+old Smart Client never asks for leaves, an old Event Server never answers LeafRequest — the
+tunnel then fails closed with a logged mint refusal), plus the one-time machine trust setup:
+
+1. **Event Server provisions its CA on demand**: on the first LeafRequest it creates (or loads)
+ `CN=XProtect WebContent Plugin Loopback Proxy` in `LocalMachine\Personal`, private key in the
+ machine key container `WebContent-Proxy-CA`. A service that cannot write that store answers
+ every LeafRequest `NoAuthority` (logged, retried per request) — tunnels stay fail-closed.
+2. **Each Smart Client machine trusts the CA once, elevated** (README "deployment certificate
+ authority"): export the CA DER from the Event Server and
+ `Import-Certificate -FilePath -CertStoreLocation Cert:\LocalMachine\Root`. The plugin
+ only READS the root stores: when the CA thumbprint is absent it logs the exact import command
+ and the browser shows its own certificate error — never a silent bypass, never a store write.
+3. **Fail-closed row**: allowlist `https://host:port` (NO flag) against a self-signed target.
+ Expected: browser tunnel TLS to the loopback listener succeeds (trusted leaf), the Event
+ Server refuses the target chain, and the browser shows the `Web Content proxy:` error page
+ whose body names the per-target `accept untrusted certificate` switch. The target machine
+ journals the TLS attempt.
+4. **Override row**: same entry plus ` accept-untrusted`. Expected: full flow (form, POST,
+ 302+Set-Cookie, cookie-gated dashboard, background XHR) through the terminated tunnel.
+5. Automatable: `ScProxyLoginProbeTests` probe facts `ProbeProxiedHttpsTargetFailsClosedWithout-
+ Override` and `ProbeProxiedHttpsLoginFlowWithAcceptUntrustedOverride` (self-signed HTTPS
+ `LoginTestServer` on `UX_HTTPS_LOGIN_TARGET`, default `https://172.16.128.210:18124`; each
+ row rewrites its own allowlist line — same origin with a different flag state replaces, never
+ contradicts — and writes `sc-login-probe-tls-{closed,open}-*.png/.txt` artifacts).
+6. Live net472 plumbing proof (2026-10-31, compiled plugin DLL reflected under PS 5.1):
+ unprivileged CA load throws (NoAuthority path); an elevated process creates the CA and a
+ SECOND process reloads the SAME thumbprint with a usable `PrivateKey` (the machine CAPI
+ container survives the process); product `ProxyLoopbackClientKey` SPKI → product
+ `MintLeafForPublicKey` → `AttachLeaf` → real Tls12 `AuthenticateAsServer` handshake
+ completes; chain builds leaf→CA (UntrustedRoot until the LM\Root import exists — correct
+ fail-closed); `ProxyLoopbackTrust.IsRootTrusted` reads both root stores unelevated and
+ reports False when the CA is absent.
+
+ Record (SC-3 live rows): pending — run after the #56 drop deploys to both hosts and the CA
+ import lands on the Smart Client machine.
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
@@ -246,6 +286,9 @@ simultaneous.
| Repeated `Set-Cookie` values comma-joined or login 3xx followed server-side, so the session cookie never reaches the browser and vendor logins loop back to the form (#55) | `ProxyFetchTransportTests` (repeated Set-Cookie captured per value; 302+Set-Cookie relayed, never followed), `ProxyForwardConnectionTests` (separate Set-Cookie lines rendered; POST body+Cookie to the seam), SC-2 |
| Request method tables drifting between the client forwarder and the Event Server, so a browser POST forwards locally then dies as a server-side refusal (#55) | One shared `ProxyForwardPolicy.IsForwardedMethod` table used on both sides; `ProxyForwardPolicyTests` (verb table), `ProxyFetchTransportTests` (verb replay) |
| Cookie/Authorization headers silently stripped at the proxy, breaking exactly the login flows #55 exists for | `ProxyForwardCodecTests.ToOutboundRequestHeaders` pins (Cookie/Authorization/custom kept verbatim; only hop-by-hop+Host+Content-Length dropped), `ProxyFetchTransportTests` replay pin |
+| Global (rather than per-target) TLS bypass on the Event Server, so one admin flag makes every proxied https device unvalidated (#56) | `ProxyFetchTlsDecisionTests` (override consulted per allowlist entry; default fail-closed `TlsValidation`), `ProxyServerAllowlistTests` (flag round-trips with its entry only) |
+| Leaf private keys crossing the wire or a session CA written into the user root store (crypt32's modal consent dialog hangs unattended sessions; ADR-0002's superseded shape) (#56, ADR-0003) | `ProxyLeafContractTests` (request carries only the SPKI; response only public DERs), `ProxyFetchReplyGuardTests` (leaf replies unicasted), `ProxyLoopbackTls`/`ProxyLoopbackTrust` (no store-write path exists on the client), SC-3 |
+| Minting gate decoupled from the fetch allowlist, so a leaf could be signed for an origin the fetcher would refuse (forged-tunnel potential) (#56) | `ProxyLeafContractTests` (same union allowlist gates minting; `NotAllowed` fail-closed before first resolve), `ProxyFetchReplyGuardTests` (LeafRequestId filter + unicast) |
## Scope notes (deliberate)
From 323937659e92c470634e18d1949d1ef641801517 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:20:03 -0700
Subject: [PATCH 028/187] fix(ux): #56 harness survives the Management Client
save-confirmation modal - UIA Invoke blocks for the whole modal lifetime (the
hang a human had to click through), so Save clicks on a side thread while the
harness sweeps modals, verifies the proxy-target readback after save, and
retires MC (close + modal sweep + kill) instead of leaving it open for the
Smart Client phase
---
.../Harness/ClientSession.cs | 4 +-
.../Harness/ManagementClientUi.cs | 80 +++++++++++++++++++
.../ScProxyLoginProbeTests.cs | 29 ++++++-
3 files changed, 109 insertions(+), 4 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/ClientSession.cs b/tests/ux/WebContent.UxTests/Harness/ClientSession.cs
index 00cc8da..1733eb8 100644
--- a/tests/ux/WebContent.UxTests/Harness/ClientSession.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ClientSession.cs
@@ -154,7 +154,7 @@ public Window FindWindow(Func predicate) =>
public void Dispose()
{
- Automation.Dispose();
- Application.Dispose();
+ try { Automation.Dispose(); } catch { }
+ try { Application.Dispose(); } catch { }
}
}
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index 9e0fc83..5df8d6c 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -771,6 +771,86 @@ public static bool Save(Window mainWindow, int timeoutSeconds = 20)
return false;
}
+ ///
+ /// Session-aware Save: clicking Save on the proxy-target page raises a shell confirmation
+ /// modal ("Save changes?"). UIA Invoke BLOCKS for the whole modal lifetime — a harness
+ /// thread inside Invoke cannot clear the modal it is waiting on, which is exactly how the
+ /// #56 row hung until a human clicked Yes. So the click runs on a side thread while this
+ /// thread sweeps modals; a modal that is already up before the click is swept first.
+ ///
+ public static bool Save(ClientSession session, Window mainWindow, int timeoutSeconds = 20)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ AcceptPendingModal(session, 1);
+ var button = mainWindow.FindAllDescendants(cf => cf.ByName("Save"))
+ .FirstOrDefault(b => b.ControlType == ControlType.Button && b.IsEnabled);
+ if (button != null)
+ {
+ var invoke = System.Threading.Tasks.Task.Run(() => button.Patterns.Invoke.Pattern.Invoke());
+ if (!invoke.Wait(3000))
+ {
+ // Still blocked: a modal owns the click. Accept it, then the click returns.
+ AcceptPendingModal(session, 10);
+ invoke.Wait(TimeSpan.FromSeconds(20));
+ }
+ else
+ {
+ AcceptPendingModal(session, 4);
+ }
+
+ Thread.Sleep(2500);
+ AcceptPendingModal(session, 2);
+ return true;
+ }
+ Thread.Sleep(500);
+ }
+ return false;
+ }
+
+ ///
+ /// Clicks the affirmative button of any pending MC modal dialog (the "Save changes?"
+ /// shell confirmation, a #32770 MessageBox, or a similar notice) that is not the main
+ /// window. Returns true when a modal was accepted. Button preference: Yes, Save, OK.
+ ///
+ public static bool AcceptPendingModal(ClientSession session, int timeoutSeconds = 5)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ foreach (var window in session.Windows())
+ {
+ try
+ {
+ var name = window.Name ?? string.Empty;
+ var cls = window.ClassName ?? string.Empty;
+ if (name.Contains("Management Client", StringComparison.OrdinalIgnoreCase)) continue;
+ var isModal = cls.Contains("#32770")
+ || name.Contains("save changes", StringComparison.OrdinalIgnoreCase)
+ || name.Contains("confirm", StringComparison.OrdinalIgnoreCase)
+ || name.Contains("warning", StringComparison.OrdinalIgnoreCase);
+ if (!isModal) continue;
+
+ var buttons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button));
+ foreach (var wanted in new[] { "Yes", "Save", "OK" })
+ {
+ var hit = buttons.FirstOrDefault(b =>
+ string.Equals((b.Name ?? string.Empty).Trim(), wanted, StringComparison.OrdinalIgnoreCase));
+ if (hit != null)
+ {
+ hit.Patterns.Invoke.Pattern.Invoke();
+ return true;
+ }
+ }
+ }
+ catch { }
+ }
+ Thread.Sleep(400);
+ }
+ return false;
+ }
+
/// Handles the yes/no confirmation MessageBox WinForms pops for deletes.
public static void AcceptConfirmation(ClientSession session, string buttonName = "Yes", int timeoutSeconds = 10)
{
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index c24153e..8ffc96a 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -337,6 +337,7 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
if (lines.Any(line => string.Equals(line, entry, StringComparison.OrdinalIgnoreCase)))
{
log.AppendLine($"entry '{entry}' already allowlisted; no Management Client edit needed");
+ RetireManagementClient(mc, mcMain);
return;
}
@@ -349,12 +350,36 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
lines.Add(entry);
ManagementClientUi.SetValue(mcMain, "txtProxyTargets", string.Join("\n", lines));
- if (!ManagementClientUi.Save(mcMain, 20))
+ if (!ManagementClientUi.Save(mc, mcMain, 20))
{
throw new InvalidOperationException($"Save never became enabled after adding '{entry}' to the proxy targets.");
}
- log.AppendLine($"allowlisted '{entry}' and saved");
+ // Read the pane back: a save the shell silently discarded (or a confirmation whose
+ // No/Cancel path was taken) must fail here, not strand the Smart Client phase with a
+ // stale allowlist.
+ Thread.Sleep(1500);
+ var after = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 15));
+ if (after == null || !after.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Any(line => line.Trim().Equals(entry, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException($"After Save, the proxy targets read back '{after}' — '{entry}' did not stick.");
+ }
+
+ log.AppendLine($"allowlisted '{entry}' and saved (verified readback)");
+ RetireManagementClient(mc, mcMain);
+ }
+
+ ///
+ /// Closes Management Client after its phase: an MC left open can hold a pending
+ /// "Save changes?" modal that steals focus and blocks UIA for the Smart Client phase
+ /// (the #56 hang). Close politely, sweep any confirmation it raises, then guarantee exit.
+ ///
+ private static void RetireManagementClient(ClientSession mc, FlaUI.Core.AutomationElements.Window mcMain)
+ {
+ try { mcMain.Close(); } catch { }
+ ManagementClientUi.AcceptPendingModal(mc, 5);
+ ProcessKiller.KillClients("VideoOS.Administration");
}
private static string ReadValue(AutomationElement element)
From 2e84c1cfe29c3fe837dc8ff4eb585f2c6b3542ca Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:39:34 -0700
Subject: [PATCH 029/187] Add elevated Event Server CA provisioning runbook and
script
Live Event Server installs run MilestoneEventServerService as
NT AUTHORITY\NETWORK SERVICE, which cannot create the deployment CA in
LocalMachine\Personal or read its machine key. One-time elevated setup
(script) now creates the CA, grants the service account read access to
the private key, and exports the certificate for client LocalMachine\Root
import; README and ADR-0003 record the corrected lifecycle.
---
README.md | 25 ++++--
...-event-server-mints-loopback-leaf-certs.md | 9 +-
scripts/provision-event-server-authority.ps1 | 84 +++++++++++++++++++
3 files changed, 107 insertions(+), 11 deletions(-)
create mode 100644 scripts/provision-event-server-authority.ps1
diff --git a/README.md b/README.md
index 1611fc6..9bb846a 100644
--- a/README.md
+++ b/README.md
@@ -70,16 +70,25 @@ A workspace proxy allowlist may include `https://` targets (optionally flagged
To make those pages render without browser certificate errors, the plugin uses one long-lived certificate
authority per deployment (see `docs/adr/0003-event-server-mints-loopback-leaf-certs.md`):
-1. **The Event Server creates the CA by itself.** The plugin deployed on the Event Server creates a CA
- certificate named `XProtect WebContent Plugin Loopback Proxy` in `LocalMachine\Personal` the first time a
- proxied HTTPS page needs it. Its private key never leaves that machine. (If the Event Server service does
- not run as LocalSystem, grant it write access to that store, or import a CA you provisioned yourself.)
-2. **Each Smart Client machine trusts the CA once, elevated.** Export the CA certificate from the Event
- Server machine (`Get-ChildItem Cert:\LocalMachine\My | Where Subject -match 'Loopback Proxy' |
- Export-Certificate -FilePath C:\webcontent-proxy-ca.cer -Type CERT`) and on every Smart Client machine run:
+1. **Create the CA once on the Event Server machine, elevated.** The Event Server service runs as a
+ restricted built-in account (`NT AUTHORITY\NETWORK SERVICE` on current Milestone installs) that cannot
+ create the CA itself. Run this once from an elevated Windows PowerShell 5.1 session on the Event Server
+ machine, after the plugin is deployed there:
```powershell
- Import-Certificate -FilePath C:\webcontent-proxy-ca.cer -CertStoreLocation Cert:\LocalMachine\Root
+ powershell -ExecutionPolicy Bypass -File scripts/provision-event-server-authority.ps1
+ ```
+
+ It creates the CA certificate named `XProtect WebContent Plugin Loopback Proxy` in the LocalMachine
+ Personal store, grants the service account read access to the private key so leaf minting works at
+ runtime, and exports the certificate to `C:/Users/Public/WebContent-Proxy-Authority.cer`. The private
+ key never leaves that machine.
+2. **Each Smart Client machine trusts the CA once, elevated.** Copy the exported
+ `WebContent-Proxy-Authority.cer` from the Event Server machine's `C:/Users/Public` folder, then on every
+ Smart Client machine run:
+
+ ```powershell
+ Import-Certificate -FilePath C:\Users\Public\WebContent-Proxy-Authority.cer -CertStoreLocation Cert:\LocalMachine\Root
```
Until that import is done, proxied HTTPS pages load but show a browser certificate error — the plugin
diff --git a/docs/adr/0003-event-server-mints-loopback-leaf-certs.md b/docs/adr/0003-event-server-mints-loopback-leaf-certs.md
index 48046b1..7be3f8b 100644
--- a/docs/adr/0003-event-server-mints-loopback-leaf-certs.md
+++ b/docs/adr/0003-event-server-mints-loopback-leaf-certs.md
@@ -33,9 +33,12 @@ Split the CA's two halves by trust boundary:
1. **One long-lived CA per deployment, created on the Event Server.** The Service-environment
background plugin loads it from `LocalMachine\Personal` by common name on start and creates it
- on first run (the Event Server service runs as LocalSystem, which may write the machine store;
- the CAPI key lives in a fixed machine key container readable only machine-privileged accounts).
- Client machines never hold the CA private key.
+ on first run when the service account can write the machine store. Live installs run the Event
+ Server service as NETWORK SERVICE, which cannot create it; an administrator then runs
+ scripts/provision-event-server-authority.ps1 once, elevated, which creates the CA, grants the
+ service account read access to its private key, and exports the certificate for client import.
+ The key lives in a fixed machine key container readable only by machine-privileged accounts
+ plus that granted service read access. Client machines never hold the CA private key.
2. **Leaves are minted by the Event Server on request.** A new message pair
(`WebContent.Proxy.LeafRequest` / `WebContent.Proxy.LeafResponse`, same JSON-string-on-the-wire
rule as #54) carries the client's base64 **SubjectPublicKeyInfo**: the client generates the
diff --git a/scripts/provision-event-server-authority.ps1 b/scripts/provision-event-server-authority.ps1
new file mode 100644
index 0000000..e63cc8d
--- /dev/null
+++ b/scripts/provision-event-server-authority.ps1
@@ -0,0 +1,84 @@
+<#
+.SYNOPSIS
+ One-time elevated setup of the WebContent proxy certificate authority on the
+ XProtect Event Server machine (ADR-0003). Run this ONCE per deployment, from
+ an elevated Windows PowerShell 5.1 session on the Event Server machine.
+
+.DESCRIPTION
+ The proxy fetcher mints short-lived loopback leaf certificates from a
+ deployment CA whose certificate and private key live in the LocalMachine
+ Personal store, under the fixed machine key container "WebContent-Proxy-CA".
+ The Event Server service runs as a restricted built-in account
+ (NT AUTHORITY\NETWORK SERVICE on current Milestone installs) that cannot
+ create the CA itself, so an administrator creates it here once. The script
+ then grants the service account read access to the CA private key so leaf
+ minting can proceed at runtime.
+
+ After this script, copy the exported CA certificate to every client machine
+ and import it into LocalMachine\Root (see README), because Smart Client
+ installs are not elevated during plugin load.
+
+.EXAMPLE
+ powershell -ExecutionPolicy Bypass -File provision-event-server-authority.ps1
+#>
+
+param([string] $PluginDll = 'C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin\WebContent.dll')
+
+$ErrorActionPreference = 'Stop'
+if (-not (Test-Path $PluginDll)) { throw "WebContent.dll not found at $PluginDll - deploy the plugin first." }
+
+$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
+if (-not $isAdmin) { throw 'Run this script from an elevated (Administrator) Windows PowerShell 5.1 session.' }
+if ($PSVersionTable.PSVersion.Major -ge 7) { throw 'Run this script in Windows PowerShell 5.1 (powershell.exe); PowerShell 7 cannot create the CAPI machine key.' }
+
+# LoadFrom (not Add-Type) so the MIP SDK types the plugin assembly also
+# references are never enumerated; only the crypto types are used here.
+$asm = [Reflection.Assembly]::LoadFrom($PluginDll)
+
+$csharp = 'using System;' + [char]10 + 'public static class ProvisionLog {' + [char]10 +
+ ' public static readonly Action Act = m => Console.WriteLine(("' + 'LOG:' + ' " + m));' + [char]10 + '}'
+Add-Type -TypeDefinition $csharp
+$log = [ProvisionLog]::Act
+
+$authType = $asm.GetType('WebContent.Authority')
+$load = $authType.GetMethod('LoadOrCreateDeploymentAuthority', [Reflection.BindingFlags]'Public,NonPublic,Static')
+$createdBefore = Get-Date
+$authority = $load.Invoke($null, [object[]] @($log))
+
+$cert = $authType.GetProperty('Certificate', [Reflection.BindingFlags]'NonPublic,Instance').GetValue($authority)
+Write-Output ('CA subject: ' + $cert.Subject)
+Write-Output ('CA thumbprint: ' + $cert.Thumbprint)
+if (-not $cert.HasPrivateKey) { throw 'Loaded CA has no private key - the machine key container is not readable by this account.' }
+
+# Export the CA certificate (public part) for the client-side LocalMachine\Root import.
+$cerPath = Join-Path $env:PUBLIC 'WebContent-Proxy-Authority.cer'
+[IO.File]::WriteAllBytes($cerPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
+Write-Output ('CA exported: ' + $cerPath)
+
+# The CAPI machine key file lands in the MachineKeys folder at creation time.
+# Its file name is the lowercase MD5 hex of the container name (try both the
+# ANSI and UTF-16 spellings); fall back to a creation-time window. Then grant
+# the Event Server service account Read so the plugin can mint leaves at runtime.
+$svc = 'NT AUTHORITY\NETWORK SERVICE'
+$keyDir = Join-Path $env:ProgramData 'Microsoft\Crypto\RSA\MachineKeys'
+$container = 'WebContent-Proxy-CA'
+$candidates = @()
+foreach ($encoding in @([Text.Encoding]::ASCII, [Text.Encoding]::Unicode)) {
+ $hash = [System.BitConverter]::ToString([System.Security.Cryptography.MD5]::Create().ComputeHash($encoding.GetBytes($container))).Replace('-','').ToLower()
+ $candidates += @(Get-ChildItem $keyDir -File -Filter $hash -EA SilentlyContinue)
+}
+if ($candidates.Count -eq 0) {
+ $candidates = @(Get-ChildItem $keyDir -File | Where-Object { $_.CreationTime -ge $createdBefore.AddSeconds(-120) })
+}
+if ($candidates.Count -eq 0) {
+ Write-Output 'NOTE: no machine key file located. If minting later fails with Access is denied, grant Read on the WebContent-Proxy-CA key file to the service account.'
+}
+foreach ($k in $candidates) {
+ $acl = Get-Acl $k.FullName
+ $rule = New-Object Security.AccessControl.FileSystemAccessRule($svc, 'Read', 'Allow')
+ $acl.AddAccessRule($rule)
+ Set-Acl $k.FullName $acl
+ Write-Output ('key ACL: ' + $k.Name + ' -> Read for ' + $svc)
+}
+
+Write-Output 'Done. Import the exported .cer into LocalMachine\Root on every Smart Client machine (elevated Import-Certificate), then restart the Smart Client.'
From ba999065ff5b246e4dc942b1b447fcca1cc53b10 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:57:15 -0700
Subject: [PATCH 030/187] Load the deployment CA read-only when the service
account cannot write the machine store
Live Event Server runs as NT AUTHORITY\NETWORK SERVICE, which has only
ReadKey on the HKLM My certificate store: X509Store.Open(ReadWrite)
throws Access is denied before the existing CA is ever found, so every
leaf request answered NoAuthority even after provisioning. The store is
now opened read-write first (create-when-allowed) and otherwise
read-only for the admin-provisioned CA; a missing CA in read-only mode
throws a message naming the one-time elevated provisioning script, which
surfaces through NoAuthority into both logs. Probe harness now logs the
full stack and flattened AggregateException inners instead of an empty
One or more errors occurred. ()
---
src/WebContent/ProxyLoopbackAuthority.cs | 46 +++++++++++++++++--
.../ScProxyLoginProbeTests.cs | 10 +++-
2 files changed, 49 insertions(+), 7 deletions(-)
diff --git a/src/WebContent/ProxyLoopbackAuthority.cs b/src/WebContent/ProxyLoopbackAuthority.cs
index 128dfdd..d222a3e 100644
--- a/src/WebContent/ProxyLoopbackAuthority.cs
+++ b/src/WebContent/ProxyLoopbackAuthority.cs
@@ -52,15 +52,28 @@ internal static Authority Create(Action log)
/// ADR-0003: the long-lived deployment authority of this Event Server machine. Loads the
/// existing CA from LocalMachine\Personal (any valid copy whose key this process can use)
/// and otherwise creates it: a machine-key-store CAPI container under a fixed name, signed
- /// self, stored with its private key. Requires machine-level privileges (the Event Server
- /// service's LocalSystem identity covers it); access failures propagate so the leaf
- /// issuer can answer NoAuthority and retry provisioning on the next request.
+ /// self, stored with its private key. The live Event Server service runs as NETWORK SERVICE,
+ /// which cannot write the machine certificate store, so the store is opened read-write
+ /// first (creating when allowed) and otherwise read-only for the admin-provisioned CA;
+ /// a missing CA in that mode names the one-time elevated provisioning script. Access
+ /// failures propagate so the leaf issuer can answer NoAuthority and retry on the next request.
///
internal static Authority LoadOrCreateDeploymentAuthority(Action log)
{
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
{
- store.Open(OpenFlags.ReadWrite);
+ var canWrite = true;
+ try
+ {
+ store.Open(OpenFlags.ReadWrite);
+ }
+ catch (Exception)
+ {
+ // Restricted service accounts (NETWORK SERVICE) may read but never write the
+ // machine store; an administrator provisions the CA once with the README script.
+ store.Open(OpenFlags.ReadOnly);
+ canWrite = false;
+ }
var now = DateTime.Now;
foreach (var existing in store.Certificates.Find(
@@ -93,6 +106,12 @@ internal static Authority LoadOrCreateDeploymentAuthority(Action log)
return new Authority(existing, key);
}
+ if (!canWrite)
+ {
+ throw new InvalidOperationException(
+ "No deployment proxy CA exists in LocalMachine\\Personal and this service account cannot create one; run the elevated provisioning step from the plugin README (scripts/provision-event-server-authority.ps1) on the Event Server machine.");
+ }
+
var parameters = new CspParameters
{
KeyContainerName = DeploymentCaContainer,
@@ -519,7 +538,18 @@ internal static Authority LoadOrCreateDeploymentAuthority(Action log)
{
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
{
- store.Open(OpenFlags.ReadWrite);
+ var canWrite = true;
+ try
+ {
+ store.Open(OpenFlags.ReadWrite);
+ }
+ catch (Exception)
+ {
+ // Restricted service accounts may read but never write the machine store;
+ // an administrator provisions the CA once (README script).
+ store.Open(OpenFlags.ReadOnly);
+ canWrite = false;
+ }
var now = DateTime.Now;
foreach (var existing in store.Certificates.Find(
@@ -532,6 +562,12 @@ internal static Authority LoadOrCreateDeploymentAuthority(Action log)
}
}
+ if (!canWrite)
+ {
+ throw new InvalidOperationException(
+ "No deployment proxy CA exists in LocalMachine\\Personal and this service account cannot create one; run the elevated provisioning step from the plugin README (scripts/provision-event-server-authority.ps1) on the Event Server machine.");
+ }
+
var key = RSA.Create(2048);
var request = new CertificateRequest(
new X500DistinguishedName("CN=" + ProxyLoopbackTls.AuthorityCommonName),
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 8ffc96a..2c45244 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -262,8 +262,14 @@ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
}
catch (Exception ex)
{
- failure = $"{ex.GetType().Name}: {ex.Message}";
- log.AppendLine($"probe failed: {failure}");
+ var detail = ex.Message;
+ if (ex is AggregateException agg)
+ {
+ detail = string.Join(";", agg.Flatten().InnerExceptions.Select(i => i.GetType().Name + ": " + i.Message));
+ }
+ failure = ex.GetType().Name + ": " + detail;
+ log.AppendLine("probe failed: " + failure);
+ log.AppendLine(ex.ToString());
}
finally
{
From 7a62547a828d49d11a52be93cb80bf0298b7d816 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:00:18 -0700
Subject: [PATCH 031/187] Retry Save when MC disables the button between lookup
and UIA Invoke
IsEnabled raced MC's pane re-render after the proxy-target edit: Invoke
died with ElementNotEnabledException (0x80040200) inside the side
thread, surfaced as an empty-message AggregateException, and the
allowlist entry (accept-untrusted) never saved. Save now catches the
stale-button aggregate and re-finds the button inside the deadline.
---
.../Harness/ManagementClientUi.cs | 40 +++++++++++++++----
1 file changed, 33 insertions(+), 7 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index 5df8d6c..591c3f8 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -788,16 +788,26 @@ public static bool Save(ClientSession session, Window mainWindow, int timeoutSec
.FirstOrDefault(b => b.ControlType == ControlType.Button && b.IsEnabled);
if (button != null)
{
- var invoke = System.Threading.Tasks.Task.Run(() => button.Patterns.Invoke.Pattern.Invoke());
- if (!invoke.Wait(3000))
+ try
{
- // Still blocked: a modal owns the click. Accept it, then the click returns.
- AcceptPendingModal(session, 10);
- invoke.Wait(TimeSpan.FromSeconds(20));
+ var invoke = System.Threading.Tasks.Task.Run(() => button.Patterns.Invoke.Pattern.Invoke());
+ if (!invoke.Wait(3000))
+ {
+ // Still blocked: a modal owns the click. Accept it, then the click returns.
+ AcceptPendingModal(session, 10);
+ invoke.Wait(TimeSpan.FromSeconds(20));
+ }
+ else
+ {
+ AcceptPendingModal(session, 4);
+ }
}
- else
+ catch (AggregateException agg) when (IsStaleButton(agg))
{
- AcceptPendingModal(session, 4);
+ // MC re-renders the pane after an edit and disables Save for a moment;
+ // IsEnabled raced the Invoke. Re-find the button and retry inside the deadline.
+ Thread.Sleep(500);
+ continue;
}
Thread.Sleep(2500);
@@ -809,6 +819,22 @@ public static bool Save(ClientSession session, Window mainWindow, int timeoutSec
return false;
}
+ /// True when the aggregate carries only "element not enabled" UIA failures (0x80040200).
+ private static bool IsStaleButton(AggregateException aggregate)
+ {
+ foreach (var inner in aggregate.Flatten().InnerExceptions)
+ {
+ var code = (inner as System.Runtime.InteropServices.COMException)?.ErrorCode;
+ var stale = inner.GetType().Name == "ElementNotEnabledException" || code == unchecked((int)0x80040200);
+ if (!stale)
+ {
+ return false;
+ }
+ }
+
+ return aggregate.InnerExceptions.Count > 0;
+ }
+
///
/// Clicks the affirmative button of any pending MC modal dialog (the "Save changes?"
/// shell confirmation, a #32770 MessageBox, or a similar notice) that is not the main
From 06615bc6b6174a339b363f6864f6d2173e324a01 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:09:19 -0700
Subject: [PATCH 032/187] Terminate tunnel responses inside the TLS stream, not
the raw socket
After CONNECT 200 the browser only reads TLS records, but the in-tunnel
request loop rendered every inner response onto the RAW socket: the
live browser aborted the tunnel with a decryption failure and showed a
chrome-error page even though the Event Server fetch succeeded end to
end (server journal saw GET /, leaf minted, tunnel TLS established).
Identity-unwrap unit seams masked it because read and write were the
same stream; a new duplex-seam test pins that plaintext never reaches
the raw socket and inner answers ride the tunnel.
---
.../ProxyForwardConnectionTests.cs | 73 ++++++++++++++++++-
src/WebContent/ProxyForwardConnection.cs | 16 ++--
2 files changed, 80 insertions(+), 9 deletions(-)
diff --git a/src/WebContent.Tests/ProxyForwardConnectionTests.cs b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
index 7467937..c40843c 100644
--- a/src/WebContent.Tests/ProxyForwardConnectionTests.cs
+++ b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
@@ -386,6 +386,70 @@ await ProxyForwardConnection.HandleAsync(
Assert.False(tunnel.CanRead);
}
+ [Fact]
+ public async Task WritesInnerTunnelResponsesIntoTheTunnelNotOntoTheRawSocket()
+ {
+ // A terminated tunnel is a SEPARATE duplex stream in production (the SslStream
+ // wrapper): after the CONNECT 200 the browser only understands TLS records, so the
+ // inner responses must be written into the tunnel. Writing plaintext onto the raw
+ // socket made the live browser fail the tunnel with a TLS decryption error (#56).
+ var inner = new DuplexStream(
+ new MemoryStream(Encoding.UTF8.GetBytes("GET /live HTTP/1.1\r\nHost: cam1.test:8443\r\n\r\n")),
+ new MemoryStream());
+
+ using (var input = new MemoryStream(Encoding.UTF8.GetBytes("CONNECT cam1.test:8443 HTTP/1.1\r\n\r\n")))
+ using (var output = new MemoryStream())
+ {
+ await ProxyForwardConnection.HandleAsync(
+ input,
+ output,
+ new[] { "https://cam1.test:8443" },
+ query => Task.FromResult(new ProxyFetchResult
+ {
+ Success = true,
+ StatusCode = 200,
+ StatusText = "OK",
+ Body = Encoding.UTF8.GetBytes("tunnel-body"),
+ }),
+ message => { },
+ (stream, leftover, tunnelOrigin) => Task.FromResult(inner));
+
+ var raw = Encoding.UTF8.GetString(output.ToArray());
+ var inside = Encoding.UTF8.GetString(inner.WrittenBuffer.ToArray());
+
+ Assert.Equal("HTTP/1.1 200 Connection established\r\n\r\n", raw);
+ Assert.Contains("HTTP/1.1 200 OK\r\n", inside);
+ Assert.Contains("tunnel-body", inside);
+ }
+ }
+
+ /// Read side and write side are different streams, like a real SslStream tunnel.
+ private sealed class DuplexStream : Stream
+ {
+ private readonly Stream _readSide;
+ private readonly MemoryStream _writeSide;
+
+ internal DuplexStream(Stream readSide, MemoryStream writeSide)
+ {
+ _readSide = readSide;
+ _writeSide = writeSide;
+ }
+
+ internal MemoryStream WrittenBuffer => _writeSide;
+
+ public override bool CanRead => _readSide.CanRead;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => _writeSide.Length;
+ public override long Position { get => _writeSide.Position; set => throw new NotSupportedException(); }
+
+ public override void Flush() => _writeSide.Flush();
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override int Read(byte[] buffer, int offset, int count) => _readSide.Read(buffer, offset, count);
+ public override void Write(byte[] buffer, int offset, int count) => _writeSide.Write(buffer, offset, count);
+ }
+
private static async Task Handle(
string request,
Func> fetch,
@@ -404,17 +468,18 @@ private static async Task HandleTunnel(
string[] allowlist,
Func> fetch)
{
+ MemoryStream combined = null;
using (var input = new MemoryStream(Encoding.UTF8.GetBytes(request)))
using (var output = new MemoryStream())
{
// Identity unwrap: the CONNECT head's pipelined leftover plus the rest of the
// stream becomes the decrypted tunnel stream - exactly the rebase the SslStream
// wrapper performs in production (the ClientHello can ride the CONNECT segment).
+ combined = new MemoryStream();
await ProxyForwardConnection.HandleAsync(
input, output, allowlist, fetch, message => { },
(stream, leftover, tunnelOrigin) =>
{
- var combined = new MemoryStream();
if (leftover != null && leftover.Length > 0)
{
combined.Write(leftover, 0, leftover.Length);
@@ -430,7 +495,11 @@ await ProxyForwardConnection.HandleAsync(
combined.Position = 0;
return Task.FromResult(combined);
});
- return Encoding.UTF8.GetString(output.ToArray());
+
+ // The raw socket carries the CONNECT handshake; inner responses live on the
+ // tunnel side (this identity seam folds them into the same buffer behind the
+ // echoed inner requests).
+ return Encoding.UTF8.GetString(output.ToArray()) + Encoding.UTF8.GetString(combined.ToArray());
}
}
diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs
index 38b7130..c2f506c 100644
--- a/src/WebContent/ProxyForwardConnection.cs
+++ b/src/WebContent/ProxyForwardConnection.cs
@@ -112,7 +112,7 @@ internal static async Task HandleAsync(
try
{
- await ServeTunnelRequestsAsync(tunnel, output, canonicalTargets, request.Uri, fetch, log);
+ await ServeTunnelRequestsAsync(tunnel, canonicalTargets, request.Uri, fetch, log);
}
finally
{
@@ -135,11 +135,13 @@ internal static async Task HandleAsync(
///
/// The request loop inside one terminated tunnel (#56): Chromium pipelines several inner
/// requests over a single TLS connection, each origin-form against the tunnel origin that
- /// the allowlist already admitted when the tunnel was accepted.
+ /// the allowlist already admitted when the tunnel was accepted. After the CONNECT 200 the
+ /// browser only reads TLS records, so every inner answer goes back through the tunnel
+ /// stream - writing plaintext to the raw socket makes the browser fail the tunnel with a
+ /// decryption error (live repro, #56).
///
private static async Task ServeTunnelRequestsAsync(
Stream tunnel,
- Stream output,
System.Collections.Generic.IEnumerable canonicalTargets,
Uri tunnelOrigin,
Func> fetch,
@@ -163,12 +165,12 @@ private static async Task ServeTunnelRequestsAsync(
case ProxyForwardDisposition.BadRequest:
// A head this broken leaves the stream position untrustworthy: answer and close.
log(rejectReason);
- await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", rejectReason));
+ await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse(400, "Bad Request", rejectReason));
return;
case ProxyForwardDisposition.MethodNotAllowed:
log(rejectReason);
- await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(405, "Method Not Allowed", rejectReason));
+ await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse(405, "Method Not Allowed", rejectReason));
pending = head.Leftover;
continue;
@@ -176,12 +178,12 @@ private static async Task ServeTunnelRequestsAsync(
// OriginNotAllowed inside a tunnel means the inner target drifted off the
// accepted origin (codec already blocks that) - refuse and keep the tunnel.
log(rejectReason ?? $"In-tunnel request '{request?.RawTarget}' refused.");
- await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(403, "Forbidden", rejectReason ?? "The in-tunnel request was refused."));
+ await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse(403, "Forbidden", rejectReason ?? "The in-tunnel request was refused."));
pending = head.Leftover;
continue;
}
- var remainder = await ServeForwardedRequestAsync(request, head.Leftover, tunnel, output, fetch, log);
+ var remainder = await ServeForwardedRequestAsync(request, head.Leftover, tunnel, tunnel, fetch, log);
if (remainder == null)
{
return;
From e6c20e2d45c640d280c42d627764064362b50f9c Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:21:23 -0700
Subject: [PATCH 033/187] Detect the MC save-confirmation modal by structure,
not title
The modal's title bar is 'Milestone XProtect Management Client ' -
identical to the main window's - so AcceptPendingModal's title filter
skipped it, Save stayed blocked inside UIA Invoke, and a human had to
click Yes. The sweep now skips only the _mainApplicationWindow and
accepts the affirmative button of any other window that pairs it with a
negative button or wears the #32770 dialog class.
---
.../Harness/ManagementClientUi.cs | 27 ++++++++++++-------
1 file changed, 18 insertions(+), 9 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index 591c3f8..f1bd97a 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -849,16 +849,25 @@ public static bool AcceptPendingModal(ClientSession session, int timeoutSeconds
{
try
{
- var name = window.Name ?? string.Empty;
+ // The save-changes confirmation carries the SAME title as the main window
+ // ("Milestone XProtect Management Client "), so titles cannot tell
+ // them apart; structure can: the main window has the application AutomationId,
+ // and a confirmation is any other window pairing an affirmative button with a
+ // negative one (Yes/No/Cancel) or wearing the dialog window class.
+ if (string.Equals(window.AutomationId ?? string.Empty, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
var cls = window.ClassName ?? string.Empty;
- if (name.Contains("Management Client", StringComparison.OrdinalIgnoreCase)) continue;
- var isModal = cls.Contains("#32770")
- || name.Contains("save changes", StringComparison.OrdinalIgnoreCase)
- || name.Contains("confirm", StringComparison.OrdinalIgnoreCase)
- || name.Contains("warning", StringComparison.OrdinalIgnoreCase);
- if (!isModal) continue;
-
- var buttons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button));
+ var buttons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)).ToList();
+ if (buttons.Count == 0) continue;
+
+ var hasNegative = buttons.Any(b =>
+ string.Equals((b.Name ?? string.Empty).Trim(), "No", StringComparison.OrdinalIgnoreCase)
+ || string.Equals((b.Name ?? string.Empty).Trim(), "Cancel", StringComparison.OrdinalIgnoreCase));
+ if (!cls.Contains("#32770") && !hasNegative) continue;
+
foreach (var wanted in new[] { "Yes", "Save", "OK" })
{
var hit = buttons.FirstOrDefault(b =>
From 55bb71823ee5791cb1509c5056bd7d715bbad61f Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:24:18 -0700
Subject: [PATCH 034/187] Classify the fail-closed document correctly:
Playwright throws on selector timeout
WaitForSelectorAsync('#signin') raises PlaywrightTimeoutException rather
than returning null, so the fail-closed probe died in the wait before
its 'no form -> expect the proxy error page' branch could run. Catch
the timeout as a null form. Matrix step 1 records the real CA
lifecycle: NETWORK SERVICE loads an admin-provisioned CA; the plugin
creates one only where the account may write the machine store.
---
docs/verification-matrix.md | 10 +++++++---
.../ux/WebContent.UxTests/ScProxyLoginProbeTests.cs | 13 ++++++++++++-
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 8903f66..0371db6 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -231,10 +231,14 @@ Requires the same both-machine drop as SC-1/SC-2 (the leaf contract is new on BO
old Smart Client never asks for leaves, an old Event Server never answers LeafRequest — the
tunnel then fails closed with a logged mint refusal), plus the one-time machine trust setup:
-1. **Event Server provisions its CA on demand**: on the first LeafRequest it creates (or loads)
+1. **Event Server loads its CA; an administrator provisions it when the service cannot create
+ it**: the plugin loads (or, where the service account may write the machine store, creates)
`CN=XProtect WebContent Plugin Loopback Proxy` in `LocalMachine\Personal`, private key in the
- machine key container `WebContent-Proxy-CA`. A service that cannot write that store answers
- every LeafRequest `NoAuthority` (logged, retried per request) — tunnels stay fail-closed.
+ machine key container `WebContent-Proxy-CA`. Live installs run the service as
+ `NT AUTHORITY\NETWORK SERVICE` (read-only on that store), so the README's elevated
+ `scripts/provision-event-server-authority.ps1` creates the CA once and grants the service read
+ access to the key. A missing/unloadable CA answers every LeafRequest `NoAuthority` (logged,
+ retried per request) — tunnels stay fail-closed.
2. **Each Smart Client machine trusts the CA once, elevated** (README "deployment certificate
authority"): export the CA DER from the Event Server and
`Import-Certificate -FilePath -CertStoreLocation Cert:\LocalMachine\Root`. The plugin
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 2c45244..7c143cb 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -204,7 +204,18 @@ private async Task RunLoginProbeAsync(
var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot);
var page = await CdpConnector.WaitForPageAsync(browser, targetUri.Host);
- var form = await page.WaitForSelectorAsync("#signin", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 });
+ // Playwright THROWS on a selector timeout; for the fail-closed row the missing form
+ // is the expected outcome (the proxy error page renders instead), so translate the
+ // timeout into a null form and let the branch below classify the document.
+ Microsoft.Playwright.IElementHandle form;
+ try
+ {
+ form = await page.WaitForSelectorAsync("#signin", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 });
+ }
+ catch (Microsoft.Playwright.PlaywrightTimeoutException)
+ {
+ form = null;
+ }
outcome.Title = await page.TitleAsync();
log.AppendLine($"first document at {target}: title='{outcome.Title}' form={form != null}");
From 30b820675bdf7fa3a447a9c2acdabde25027f058 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:28:56 -0700
Subject: [PATCH 035/187] Catch the timeout Playwright actually throws; record
the SC-3 live rows
Playwright .NET raises System.TimeoutException from WaitForSelectorAsync
(there is no PlaywrightTimeoutException type in this package), so the
fail-closed probe now classifies the absent form as the expected proxy
error document - and the live row PASSED: browser 'Web Content proxy:
502' naming the per-target switch, target server journaling the refused
TLS handshake. Matrix SC-3 Record filled with both live rows.
---
docs/verification-matrix.md | 28 +++++++++++++++++--
.../ScProxyLoginProbeTests.cs | 2 +-
2 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 0371db6..0378c56 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -265,8 +265,32 @@ tunnel then fails closed with a logged mint refusal), plus the one-time machine
fail-closed); `ProxyLoopbackTrust.IsRootTrusted` reads both root stores unelevated and
reports False when the CA is absent.
- Record (SC-3 live rows): pending — run after the #56 drop deploys to both hosts and the CA
- import lands on the Smart Client machine.
+ Record (SC-3 live rows) - PASSED live both-host (2026-10-31, drop `WebContent.dll`
+ SHA-256 `683FA932...A47A9C58` hash-verified on Smart Client + Event Server):
+
+ - Trust setup executed live: `scripts/provision-event-server-authority.ps1` (elevated, on
+ the ES) created CA thumb `E4D254D58CB1BDA6E894C0F484F3C5AA718D2377` and granted
+ `NT AUTHORITY\NETWORK SERVICE` read on the key file; the exported .cer was
+ `Import-Certificate`d into the Smart Client machine's `LocalMachine\Root`.
+ - Override row PASS (`ProbeProxiedHttpsLoginFlowWithAcceptUntrustedOverride`): ES log
+ `Loaded deployment proxy CA... (thumbprint E4D254D5...)` + `Minted loopback proxy leaf...`;
+ client log `deployment CA ... is present in this machine's trusted roots`, `tunnel TLS
+ established`; the browser completed form -> POST /login -> 302+Set-Cookie -> cookie-gated
+ dashboard -> background XHR: `signedIn='SIGNED IN' xhr='XHR-OK'`, cookie echo
+ round-tripped; the target server journaled `GET /`, `POST /login`, `GET /dashboard`,
+ `GET /api/status` carrying the session cookie.
+ - Fail-closed row PASS (`ProbeProxiedHttpsTargetFailsClosedWithoutOverride`): the same
+ allowlisted origin WITHOUT the flag - browser document `title='Web Content proxy: 502'`
+ naming the per-target accept-untrusted switch; the target server journaled
+ `TLS handshake failed` (the fetcher refused the self-signed chain mid-handshake).
+ No login form, no bypass.
+
+ Live defects these rows caught and fixed: the restricted service account's CA store open
+ (read-only load fallback, step 1) and tunnel responses rendered as plaintext onto the raw
+ socket instead of into the TLS stream (browser decryption failure; pinned by
+ `ProxyForwardConnectionTests.WritesInnerTunnelResponsesIntoTheTunnelNotOntoTheRawSocket`).
+
+ Artifacts: `%TEMP%\webcontent-sc-proxy-probe\\sc-login-probe-tls-{open,closed}-*.png/.txt.
## Known high-risk regressions → pinned by
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 7c143cb..916e84b 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -212,7 +212,7 @@ private async Task RunLoginProbeAsync(
{
form = await page.WaitForSelectorAsync("#signin", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 });
}
- catch (Microsoft.Playwright.PlaywrightTimeoutException)
+ catch (TimeoutException)
{
form = null;
}
From f76dcb5d71fd8c3a87d4425b42ca87eecab2bf62 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:59:28 -0700
Subject: [PATCH 036/187] Verify keyboard focus before typing the Smart Client
password
Keyboard.Type goes to the FOREGROUND window, and a bare Focus() can be
ignored while a just-retired Management Client still owns the
foreground - the SC login then sat at Connect with an empty password
and 'insufficient user rights'. FocusAndType re-activates the login
window and only types once UIA confirms _passwordBox holds focus
(mouse-click fallback after retries).
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 39 ++++++++++++++++++-
1 file changed, 37 insertions(+), 2 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index f2cc7a8..d11a8d7 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -1,3 +1,4 @@
+using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
using FlaUI.Core.Input;
@@ -35,8 +36,7 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
var passwordBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_passwordBox"));
if (passwordBox == null) throw new InvalidOperationException("Login dialog has no _passwordBox.");
- passwordBox.Focus();
- Keyboard.Type(config.VmsPassword);
+ FocusAndType(session, loginWindow, passwordBox, config.VmsPassword);
var connect = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect"))
?? loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton"));
@@ -194,4 +194,39 @@ private static void SetValueOrType(AutomationElement element, string value)
}
Keyboard.Type(value);
}
+
+ ///
+ /// Keyboard.Type lands on whatever owns the FOREGROUND window, and a plain Focus() can be
+ /// silently ignored while another app (e.g. a just-retired Management Client) still owns
+ /// the foreground - the login then proceeds with an empty password and Smart Client answers
+ /// "insufficient user rights". Verify the element actually holds focus before typing,
+ /// re-activating the window between attempts.
+ ///
+ private static void FocusAndType(ClientSession session, Window window, AutomationElement element, string text)
+ {
+ for (var attempt = 0; attempt < 6; attempt++)
+ {
+ try { window.Focus(); } catch { }
+ try { element.Focus(); } catch { }
+ try
+ {
+ var focused = session.Automation.FocusedElement();
+ if (focused != null
+ && string.Equals(focused.AutomationId, element.AutomationId, StringComparison.OrdinalIgnoreCase)
+ && focused.Properties.NativeWindowHandle.Value == window.Properties.NativeWindowHandle.Value)
+ {
+ Keyboard.Type(text);
+ return;
+ }
+ }
+ catch { }
+ Thread.Sleep(250);
+ }
+
+ // Focus never verified (some WPF builds report it oddly): mouse-click the box to force
+ // foreground + focus together, then type anyway.
+ element.Click();
+ Thread.Sleep(150);
+ Keyboard.Type(text);
+ }
}
From 715db8f1b72285ace304989f2c47fcccae2f547f Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:07:09 -0700
Subject: [PATCH 037/187] Enter client login passwords by clipboard paste, not
keystrokes
Keyboard.Type sends shifted virtual-key codes, and the VMS password
carries \$ & * @ whose mapping is keyboard-layout dependent - the
harness had been typing CORRUPTED passwords into both clients
('insufficient user rights / username or password is incorrect' on MC
and Smart Client login). TryClipboardSetText writes exact CF_UNICODETEXT
via P/Invoke and pastes with Ctrl+V; Keyboard.Type stays the fallback.
Focus verification (FocusedElement must report the target box) still
gates entry.
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 80 ++++++++++++++++++-
1 file changed, 76 insertions(+), 4 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index d11a8d7..d084cbf 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -1,7 +1,10 @@
+using System.Runtime.InteropServices;
+using System.Text;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
using FlaUI.Core.Input;
+using FlaUI.Core.WindowsAPI;
namespace WebContent.UxTests.Harness;
@@ -199,8 +202,8 @@ private static void SetValueOrType(AutomationElement element, string value)
/// Keyboard.Type lands on whatever owns the FOREGROUND window, and a plain Focus() can be
/// silently ignored while another app (e.g. a just-retired Management Client) still owns
/// the foreground - the login then proceeds with an empty password and Smart Client answers
- /// "insufficient user rights". Verify the element actually holds focus before typing,
- /// re-activating the window between attempts.
+ /// "insufficient user rights". Verify the element actually holds focus before entering the
+ /// text, re-activating the window between attempts.
///
private static void FocusAndType(ClientSession session, Window window, AutomationElement element, string text)
{
@@ -215,7 +218,7 @@ private static void FocusAndType(ClientSession session, Window window, Automatio
&& string.Equals(focused.AutomationId, element.AutomationId, StringComparison.OrdinalIgnoreCase)
&& focused.Properties.NativeWindowHandle.Value == window.Properties.NativeWindowHandle.Value)
{
- Keyboard.Type(text);
+ EnterText(text);
return;
}
}
@@ -224,9 +227,78 @@ private static void FocusAndType(ClientSession session, Window window, Automatio
}
// Focus never verified (some WPF builds report it oddly): mouse-click the box to force
- // foreground + focus together, then type anyway.
+ // foreground + focus together, then enter the text anyway.
element.Click();
Thread.Sleep(150);
+ EnterText(text);
+ }
+
+ ///
+ /// Enter text as clipboard paste (Ctrl+V) when possible: the VMS password carries characters
+ /// ($ & * @) whose shifted virtual-key mapping is layout-dependent, and Keyboard.Type had
+ /// been sending silently CORRUPTED passwords. CF_UNICODETEXT pastes exact UTF-16 regardless
+ /// of keyboard layout; Keyboard.Type remains the fallback if the clipboard is unavailable.
+ ///
+ private static void EnterText(string text)
+ {
+ if (TryClipboardSetText(text))
+ {
+ Keyboard.TypeSimultaneously(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V);
+ return;
+ }
Keyboard.Type(text);
}
+
+ [DllImport("user32.dll", SetLastError = true)] private static extern bool OpenClipboard(IntPtr newOwner);
+ [DllImport("user32.dll")] private static extern bool CloseClipboard();
+ [DllImport("user32.dll")] private static extern bool EmptyClipboard();
+ [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetClipboardData(uint format, IntPtr data);
+ [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GlobalAlloc(uint flags, UIntPtr bytes);
+ [DllImport("kernel32.dll")] private static extern IntPtr GlobalLock(IntPtr handle);
+ [DllImport("kernel32.dll")] private static extern bool GlobalUnlock(IntPtr handle);
+ [DllImport("kernel32.dll")] private static extern IntPtr GlobalFree(IntPtr handle);
+
+ private const uint CF_UNICODETEXT = 13;
+ private const uint GHND = 0x42; // GMEM_MOVEABLE | GMEM_ZEROINIT
+
+ private static bool TryClipboardSetText(string text)
+ {
+ var handle = IntPtr.Zero;
+ try
+ {
+ var bytes = (uint)((text.Length + 1) * 2);
+ handle = GlobalAlloc(GHND, (UIntPtr)bytes);
+ if (handle == IntPtr.Zero) return false;
+ var block = GlobalLock(handle);
+ if (block == IntPtr.Zero) return false;
+ Marshal.Copy(text.ToCharArray(), 0, block, text.Length); // +2 zero bytes = UTF-16 NUL terminator
+ GlobalUnlock(handle);
+
+ if (!OpenClipboard(IntPtr.Zero)) return false;
+ try
+ {
+ EmptyClipboard();
+ // On success the CLIPBOARD owns the handle - do not GlobalFree it.
+ if (SetClipboardData(CF_UNICODETEXT, handle) == IntPtr.Zero) return false;
+ handle = IntPtr.Zero;
+ return true;
+ }
+ finally
+ {
+ CloseClipboard();
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ if (handle != IntPtr.Zero)
+ {
+ try { GlobalUnlock(handle); } catch { }
+ try { GlobalFree(handle); } catch { } // clipboard never took ownership
+ }
+ }
+ }
}
From 584912e121288877389742672262023ecf7182ca Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:13:36 -0700
Subject: [PATCH 038/187] Gate login entry on foreground+focus together, log
the entry path, retry Connect
Windows routes keystrokes (and Ctrl+V) to the FOREGROUND window while
UIA focus is a separate fact: _passwordBox can hold focus under UIA
while another window owns the foreground, so the paste landed nowhere
(Connect disabled, ElementNotEnabledException, or MC 'invalid
password'). FocusAndType now requires BOTH FocusedElement==_passwordBox
AND GetForegroundWindow()==the login window before entering text; every
attempt, and whether paste or typed fallback was used, goes to the
login-entry-log.txt artifact; Login re-enters once if Connect is still
disabled, then fails with a pointer to the log.
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 71 ++++++++++++++++---
1 file changed, 60 insertions(+), 11 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index d084cbf..32b3ca0 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -1,3 +1,4 @@
+using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using FlaUI.Core;
@@ -44,6 +45,19 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
var connect = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect"))
?? loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton"));
if (connect == null) throw new InvalidOperationException("Login dialog has no connect button.");
+
+ // The login control disables Connect while it considers the input incomplete - a
+ // dropped password shows up as this, not as an error. Re-enter once before giving up.
+ if (!WaitUntilEnabled(connect, 3))
+ {
+ LogEntry($"connect disabled; re-entering password (loginWindow aid='{loginWindow.AutomationId}')");
+ Artifacts.Window(loginWindow, "login-filled-disabled");
+ FocusAndType(session, loginWindow, passwordBox, config.VmsPassword);
+ if (!WaitUntilEnabled(connect, 3))
+ throw new InvalidOperationException(
+ "Connect stayed disabled after two password entries - the text never reached _passwordBox " +
+ "(see login-entry-log.txt artifact).");
+ }
connect.Patterns.Invoke.Pattern.Invoke();
}
@@ -199,11 +213,11 @@ private static void SetValueOrType(AutomationElement element, string value)
}
///
- /// Keyboard.Type lands on whatever owns the FOREGROUND window, and a plain Focus() can be
- /// silently ignored while another app (e.g. a just-retired Management Client) still owns
- /// the foreground - the login then proceeds with an empty password and Smart Client answers
- /// "insufficient user rights". Verify the element actually holds focus before entering the
- /// text, re-activating the window between attempts.
+ /// Keyboard input (both typed keys and Ctrl+V) is routed by Windows to the FOREGROUND
+ /// window, while UIA focus is a different fact - a plain Focus() can report success while
+ /// another app still owns the foreground, so the password lands nowhere (empty box, disabled
+ /// Connect, or a stale corrupted value). Verify BOTH that _passwordBox holds UIA focus and
+ /// that its window is the foreground window before entering text.
///
private static void FocusAndType(ClientSession session, Window window, AutomationElement element, string text)
{
@@ -211,28 +225,61 @@ private static void FocusAndType(ClientSession session, Window window, Automatio
{
try { window.Focus(); } catch { }
try { element.Focus(); } catch { }
+ Thread.Sleep(150);
try
{
+ var hwnd = window.Properties.NativeWindowHandle.Value;
+ var foreground = GetForegroundWindow().ToInt64();
var focused = session.Automation.FocusedElement();
- if (focused != null
- && string.Equals(focused.AutomationId, element.AutomationId, StringComparison.OrdinalIgnoreCase)
- && focused.Properties.NativeWindowHandle.Value == window.Properties.NativeWindowHandle.Value)
+ var focusOk = focused != null
+ && string.Equals(focused.AutomationId, element.AutomationId, StringComparison.OrdinalIgnoreCase)
+ && focused.Properties.NativeWindowHandle.Value == hwnd;
+ var fgOk = foreground == (hwnd & 0xFFFFFFFFL);
+ if (focusOk && fgOk)
{
EnterText(text);
return;
}
+ LogEntry($"focus attempt {attempt}: focusOk={focusOk} fgOk={fgOk} fg=0x{foreground:X} hwnd=0x{hwnd & 0xFFFFFFFFL:X}");
+ }
+ catch (Exception ex)
+ {
+ LogEntry($"focus attempt {attempt} threw {ex.GetType().Name}: {ex.Message}");
}
- catch { }
Thread.Sleep(250);
}
- // Focus never verified (some WPF builds report it oddly): mouse-click the box to force
- // foreground + focus together, then enter the text anyway.
+ // Focus never verified: mouse-click the box (click forces foreground AND focus together),
+ // then enter the text anyway - the Connect-disabled check upstream catches a miss.
element.Click();
Thread.Sleep(150);
EnterText(text);
+ LogEntry("entry via click-fallback (focus unverified)");
}
+ private static bool WaitUntilEnabled(AutomationElement element, int seconds)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(seconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ try { if (element.IsEnabled) return true; } catch { }
+ Thread.Sleep(250);
+ }
+ try { return element.IsEnabled; } catch { return false; }
+ }
+
+ private static void LogEntry(string line)
+ {
+ try
+ {
+ File.AppendAllText(Artifacts.PathOf("login-entry-log.txt"),
+ DateTime.Now.ToString("HH:mm:ss") + " " + line + Environment.NewLine);
+ }
+ catch { }
+ }
+
+ [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
+
///
/// Enter text as clipboard paste (Ctrl+V) when possible: the VMS password carries characters
/// ($ & * @) whose shifted virtual-key mapping is layout-dependent, and Keyboard.Type had
@@ -243,9 +290,11 @@ private static void EnterText(string text)
{
if (TryClipboardSetText(text))
{
+ LogEntry("entry-via=paste (Ctrl+V)");
Keyboard.TypeSimultaneously(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V);
return;
}
+ LogEntry("entry-via=typed-keys (clipboard unavailable - password may corrupt)");
Keyboard.Type(text);
}
From 453bef6d414e880c5ff020eba2cabc40d330ea52 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:26:53 -0700
Subject: [PATCH 039/187] Route login focus/foreground checks by process id and
dump the login tree
NativeWindowHandle is UNSUPPORTED on the Milestone login windows, so
the focus gate threw on every attempt and silently fell to the
unverified click path. FocusedElement's process + AutomationId and
GetForegroundWindow's process id together give the same guarantee via
properties every window does expose. Plus a flat UIA census of the
login dialog (control/aid/name/enabled/value) whenever Connect stays
disabled - the client's own account of why it refuses.
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 54 ++++++++++++++++---
1 file changed, 48 insertions(+), 6 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index 32b3ca0..e7f724a 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -52,11 +52,15 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
{
LogEntry($"connect disabled; re-entering password (loginWindow aid='{loginWindow.AutomationId}')");
Artifacts.Window(loginWindow, "login-filled-disabled");
+ DumpLoginTree(loginWindow, "login-tree-disabled.txt");
FocusAndType(session, loginWindow, passwordBox, config.VmsPassword);
if (!WaitUntilEnabled(connect, 3))
+ {
+ DumpLoginTree(loginWindow, "login-tree-disabled2.txt");
throw new InvalidOperationException(
"Connect stayed disabled after two password entries - the text never reached _passwordBox " +
- "(see login-entry-log.txt artifact).");
+ "(see login-entry-log.txt + login-tree-disabled*.txt artifacts).");
+ }
}
connect.Patterns.Invoke.Pattern.Invoke();
}
@@ -228,19 +232,26 @@ private static void FocusAndType(ClientSession session, Window window, Automatio
Thread.Sleep(150);
try
{
- var hwnd = window.Properties.NativeWindowHandle.Value;
- var foreground = GetForegroundWindow().ToInt64();
+ // NativeWindowHandle is unsupported on these dialog windows; route checks by
+ // process instead: keystrokes go to the foreground window's process, and UIA
+ // focus + AutomationId must name _passwordBox within the SAME process (MC and
+ // SC show identically-id'd login dialogs simultaneously).
+ var pid = session.Process.Id;
+ var fgHwnd = GetForegroundWindow();
+ GetWindowThreadProcessId(fgHwnd, out var fgPid);
var focused = session.Automation.FocusedElement();
+ int focusedPid = -1;
+ try { focusedPid = focused == null ? -1 : focused.Properties.ProcessId.Value; } catch { }
var focusOk = focused != null
&& string.Equals(focused.AutomationId, element.AutomationId, StringComparison.OrdinalIgnoreCase)
- && focused.Properties.NativeWindowHandle.Value == hwnd;
- var fgOk = foreground == (hwnd & 0xFFFFFFFFL);
+ && focusedPid == pid;
+ var fgOk = fgPid == pid;
if (focusOk && fgOk)
{
EnterText(text);
return;
}
- LogEntry($"focus attempt {attempt}: focusOk={focusOk} fgOk={fgOk} fg=0x{foreground:X} hwnd=0x{hwnd & 0xFFFFFFFFL:X}");
+ LogEntry($"focus attempt {attempt}: focusOk={focusOk} (focusedAid='{focused?.AutomationId}' focusedPid={focusedPid}) fgOk={fgOk} (fgPid={fgPid} want={pid})");
}
catch (Exception ex)
{
@@ -268,6 +279,36 @@ private static bool WaitUntilEnabled(AutomationElement element, int seconds)
try { return element.IsEnabled; } catch { return false; }
}
+ /// Flat UIA census of the login dialog: what the client itself thinks each control
+ /// (especially Connect) looks like after entry - the arbiter when Connect stays disabled.
+ private static void DumpLoginTree(Window window, string fileName)
+ {
+ try
+ {
+ var sb = new StringBuilder();
+ foreach (var el in window.FindAllDescendants())
+ {
+ try
+ {
+ var val = string.Empty;
+ try
+ {
+ if (el.Patterns.Value.IsSupported)
+ val = $" value='{el.Patterns.Value.Pattern.Value}'";
+ }
+ catch { }
+ sb.AppendLine($"{el.ControlType} aid='{el.AutomationId}' name='{el.Name}' enabled={el.IsEnabled}{val}");
+ }
+ catch { }
+ }
+ Artifacts.WriteText(fileName, sb.ToString());
+ }
+ catch (Exception ex)
+ {
+ LogEntry($"login tree dump threw {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
private static void LogEntry(string line)
{
try
@@ -279,6 +320,7 @@ private static void LogEntry(string line)
}
[DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
+ [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
///
/// Enter text as clipboard paste (Ctrl+V) when possible: the VMS password carries characters
From 4234775635db0142b92c37e2579f81d451a47ad6 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:38:27 -0700
Subject: [PATCH 040/187] Tolerate client Auto-login: never fight the
connecting login dialog
An interactive session left Smart Client with Auto-login remembered,
so the launch connects itself: the dialog shows mid-attempt with
Connect DISABLED (which the harness read as 'password never landed'
and the tree-collapse saw churn under), then the main window just
appears. WaitForLoginWindow returns null when the main window wins
the race; Login skips a null/obsolete dialog and treats a disabled
Connect as 'maybe auto-connecting' - waits for the session window
before ever re-entering or throwing.
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 55 ++++++++++++++++---
1 file changed, 47 insertions(+), 8 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index e7f724a..e0572fa 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -15,14 +15,34 @@ namespace WebContent.UxTests.Harness;
///
public static class LoginFlow
{
+ ///
+ /// Waits for the login dialog. Returns NULL when the client never shows (or already closed)
+ /// it because Auto-login connected on its own - the main window is up instead. Login() and
+ /// the WaitFor*Main helpers handle that path.
+ ///
public static Window WaitForLoginWindow(ClientSession session, int timeoutSeconds = 60)
- => session.WaitForWindow(
- w => w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null ||
- w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null,
- timeoutSeconds, "login dialog");
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ var window = session.FindWindow(
+ w => w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null ||
+ w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null);
+ if (window != null) return window;
+ if (HasMainSessionWindow(session)) return null;
+ Thread.Sleep(300);
+ }
+ throw new TimeoutException($"Timed out waiting for login dialog (process {session.Process.ProcessName}, pid {session.Process.Id}).");
+ }
public static void Login(ClientSession session, Window loginWindow, UxConfig config)
{
+ if (loginWindow == null || HasMainSessionWindow(session))
+ {
+ LogEntry("main window already up (client auto-login with remembered credentials); nothing to enter");
+ return;
+ }
+
var serverBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox"));
if (serverBox != null && !string.IsNullOrWhiteSpace(config.VmsAddress))
{
@@ -46,25 +66,44 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
?? loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton"));
if (connect == null) throw new InvalidOperationException("Login dialog has no connect button.");
- // The login control disables Connect while it considers the input incomplete - a
- // dropped password shows up as this, not as an error. Re-enter once before giving up.
+ // The client may ALREADY be auto-connecting (Auto-login was remembered from an
+ // interactive session): the dialog shows filled remembered fields, Connect is disabled
+ // for the duration of the attempt, and on success the main window simply appears.
+ // Never fight that dialog - give the auto-login room, then fall back to entering.
if (!WaitUntilEnabled(connect, 3))
{
+ if (WaitForConnected(session, 10))
+ {
+ LogEntry("auto-login completed while waiting; leaving the dialog alone");
+ return;
+ }
LogEntry($"connect disabled; re-entering password (loginWindow aid='{loginWindow.AutomationId}')");
Artifacts.Window(loginWindow, "login-filled-disabled");
DumpLoginTree(loginWindow, "login-tree-disabled.txt");
FocusAndType(session, loginWindow, passwordBox, config.VmsPassword);
- if (!WaitUntilEnabled(connect, 3))
+ if (!WaitUntilEnabled(connect, 3) && !WaitForConnected(session, 5))
{
DumpLoginTree(loginWindow, "login-tree-disabled2.txt");
throw new InvalidOperationException(
- "Connect stayed disabled after two password entries - the text never reached _passwordBox " +
+ "Connect stayed disabled after two password entries and no session window appeared " +
"(see login-entry-log.txt + login-tree-disabled*.txt artifacts).");
}
+ if (HasMainSessionWindow(session)) return;
}
connect.Patterns.Invoke.Pattern.Invoke();
}
+ private static bool WaitForConnected(ClientSession session, int seconds)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(seconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ if (HasMainSessionWindow(session)) return true;
+ Thread.Sleep(400);
+ }
+ return false;
+ }
+
private static readonly string[] PreferredTrustButtonIds =
{
"_btnContinue",
From 974984ec90c1f4ba458da39efa160674f3fe6ae5 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:43:44 -0700
Subject: [PATCH 041/187] Treat a login dialog that dies mid-entry as
auto-login winning
SetFocus on the dialog threw InvalidOperationException because
Auto-login's connection completed underneath the harness and closed
the dialog: stale element, but the client is SIGNED IN. Any stale-UIA
throw mid-entry now resolves to success when the session window is
(or becomes) up.
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index e0572fa..07ce4d9 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -43,7 +43,12 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
return;
}
- var serverBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox"));
+ // The dialog may vanish mid-entry because the client's own auto-login just connected
+ // (stale element: UIA throws InvalidOperationException / ElementNotAvailable). That is a
+ // SUCCESS outcome, not a broken login - recognize it instead of faulting the probe.
+ try
+ {
+ var serverBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox"));
if (serverBox != null && !string.IsNullOrWhiteSpace(config.VmsAddress))
{
serverBox.Focus();
@@ -91,6 +96,18 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
if (HasMainSessionWindow(session)) return;
}
connect.Patterns.Invoke.Pattern.Invoke();
+ }
+ catch (Exception ex) when (ex is InvalidOperationException
+ || ex is FlaUI.Core.Exceptions.ElementNotAvailableException)
+ {
+ // Stale mid-entry: the dialog closed because the client's own auto-login connected.
+ if (HasMainSessionWindow(session) || WaitForConnected(session, 15))
+ {
+ LogEntry($"login dialog vanished mid-entry ({ex.GetType().Name}) but the session window is up - auto-login won; treating as signed in");
+ return;
+ }
+ throw;
+ }
}
private static bool WaitForConnected(ClientSession session, int seconds)
From b717d5fb85adab1a7c5cd1231b8a66b400d1948b Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:48:51 -0700
Subject: [PATCH 042/187] Never mistake the MC login window for a session
window
The Management Client login window is titled 'Milestone XProtect
Management Client ' - the main window's exact title pattern - so
the auto-login shortcut fired WHILE MC's login dialog sat on screen,
skipping entry and stranding WaitForAdminMain. Session-window evidence
now requires login controls ABSENT (_btnConnect/_connectButton nowhere
in the window) and, for MC, a >800px width (the login dialog is 602).
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 44 ++++++++++++-------
1 file changed, 28 insertions(+), 16 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index 07ce4d9..5e6ad1d 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -25,11 +25,19 @@ public static Window WaitForLoginWindow(ClientSession session, int timeoutSecond
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
+ // "Login is over" must be proven positively (session window WITHOUT login controls),
+ // not inferred from "no _btnConnect/_connectButton found": the Management Client
+ // login window carries the MAIN window's title ("Milestone XProtect Management
+ // Client "), so a title-based session match can be the login dialog itself.
+ if (HasMainSessionWindow(session))
+ {
+ LogEntry("connected without a login dialog (auto-login); no entry needed");
+ return null;
+ }
var window = session.FindWindow(
w => w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null ||
w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null);
if (window != null) return window;
- if (HasMainSessionWindow(session)) return null;
Thread.Sleep(300);
}
throw new TimeoutException($"Timed out waiting for login dialog (process {session.Process.ProcessName}, pid {session.Process.Id}).");
@@ -214,24 +222,28 @@ public static void AcceptCertificateTrust(ClientSession session, int timeoutSeco
}
}
- /// True when the client's own main window is up (Smart Client or Management Client).
- private static bool HasMainSessionWindow(ClientSession session)
+ ///
+ /// True when the client's own SESSION window is up (Smart Client or Management Client) -
+ /// positively required to be free of login controls, because the Management Client login
+ /// window shares the main window's title and would otherwise masquerade as a session.
+ ///
+ private static bool HasMainSessionWindow(ClientSession session) =>
+ session.Windows().ToList().Any(IsSessionWindowWithoutLogin);
+
+ private static bool IsSessionWindowWithoutLogin(Window w)
{
- foreach (var w in session.Windows().ToList())
+ try
{
- try
- {
- if (w.AutomationId == "_mainApplicationWindow") return true;
- var name = w.Name ?? string.Empty;
- var cls = w.ClassName ?? string.Empty;
- if (name.Contains("Management Client", StringComparison.OrdinalIgnoreCase)
- && cls.StartsWith("WindowsForms", StringComparison.Ordinal)
- && w.BoundingRectangle.Width > 400)
- return true;
- }
- catch { }
+ if (w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null) return false;
+ if (w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null) return false;
+ if (string.Equals(w.AutomationId, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)) return true;
+ var name = w.Name ?? string.Empty;
+ var cls = w.ClassName ?? string.Empty;
+ return name.Contains("Management Client", StringComparison.OrdinalIgnoreCase)
+ && cls.StartsWith("WindowsForms", StringComparison.Ordinal)
+ && w.BoundingRectangle.Width > 800;
}
- return false;
+ catch { return false; }
}
private static bool IsNegativeButton(string name) =>
From df6f2a752b8dd0e711b66c6032658fd998acb56a Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:53:52 -0700
Subject: [PATCH 043/187] Auto-login shortcut only on Smart Client's unique
main-window id
MC's MAIN shell window exists from launch (login is a separate
window), so 'a session window is up without login controls' was
already true the instant MC started - WaitForLoginWindow returned null
and skipped typing into a login dialog that had not even been created.
Connection-without-login is now proven only by Smart Client's
_mainApplicationWindow AutomationId (no other window carries it) plus
no login dialog anywhere in the process; MC always gets its dialog
driven.
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 63 +++++++++++--------
1 file changed, 36 insertions(+), 27 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index 5e6ad1d..e8ab5f2 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -16,28 +16,26 @@ namespace WebContent.UxTests.Harness;
public static class LoginFlow
{
///
- /// Waits for the login dialog. Returns NULL when the client never shows (or already closed)
- /// it because Auto-login connected on its own - the main window is up instead. Login() and
- /// the WaitFor*Main helpers handle that path.
+ /// Waits for the login dialog. Returns NULL only when the Smart Client main window (whose
+ /// _mainApplicationWindow AutomationId no other window carries) is up while NO window of the
+ /// process wears login controls - i.e. Auto-login connected on its own. That shortcut is
+ /// deliberately NOT extended to the Management Client: MC's main shell window exists from
+ /// launch, BEFORE login (login is a separate window), so its absence/presence proves nothing.
///
public static Window WaitForLoginWindow(ClientSession session, int timeoutSeconds = 60)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
- // "Login is over" must be proven positively (session window WITHOUT login controls),
- // not inferred from "no _btnConnect/_connectButton found": the Management Client
- // login window carries the MAIN window's title ("Milestone XProtect Management
- // Client "), so a title-based session match can be the login dialog itself.
- if (HasMainSessionWindow(session))
+ var window = session.FindWindow(HasLoginControls);
+ if (window != null) return window;
+ if (session.Windows().Any(w =>
+ string.Equals(w.AutomationId, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)
+ && !HasLoginControls(w)))
{
- LogEntry("connected without a login dialog (auto-login); no entry needed");
+ LogEntry("Smart Client main window up with no login dialog anywhere (auto-login); no entry needed");
return null;
}
- var window = session.FindWindow(
- w => w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null ||
- w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null);
- if (window != null) return window;
Thread.Sleep(300);
}
throw new TimeoutException($"Timed out waiting for login dialog (process {session.Process.ProcessName}, pid {session.Process.Id}).");
@@ -223,25 +221,36 @@ public static void AcceptCertificateTrust(ClientSession session, int timeoutSeco
}
///
- /// True when the client's own SESSION window is up (Smart Client or Management Client) -
- /// positively required to be free of login controls, because the Management Client login
- /// window shares the main window's title and would otherwise masquerade as a session.
+ /// True when the process shows a session window AND no login dialog anywhere:
+ /// Smart Client's _mainApplicationWindow or a >800px Management Client shell, with no
+ /// window carrying _btnConnect/_connectButton. (MC's login window is separate from its
+ /// main shell, so "no dialog" must be checked process-wide, not window-by-window.)
///
- private static bool HasMainSessionWindow(ClientSession session) =>
- session.Windows().ToList().Any(IsSessionWindowWithoutLogin);
+ private static bool HasMainSessionWindow(ClientSession session)
+ {
+ var windows = session.Windows().ToList();
+ if (windows.Any(HasLoginControls)) return false;
+ return windows.Any(w =>
+ {
+ try
+ {
+ if (string.Equals(w.AutomationId, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)) return true;
+ var name = w.Name ?? string.Empty;
+ var cls = w.ClassName ?? string.Empty;
+ return name.Contains("Management Client", StringComparison.OrdinalIgnoreCase)
+ && cls.StartsWith("WindowsForms", StringComparison.Ordinal)
+ && w.BoundingRectangle.Width > 800;
+ }
+ catch { return false; }
+ });
+ }
- private static bool IsSessionWindowWithoutLogin(Window w)
+ private static bool HasLoginControls(Window w)
{
try
{
- if (w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null) return false;
- if (w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null) return false;
- if (string.Equals(w.AutomationId, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)) return true;
- var name = w.Name ?? string.Empty;
- var cls = w.ClassName ?? string.Empty;
- return name.Contains("Management Client", StringComparison.OrdinalIgnoreCase)
- && cls.StartsWith("WindowsForms", StringComparison.Ordinal)
- && w.BoundingRectangle.Width > 800;
+ return w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null
+ || w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null;
}
catch { return false; }
}
From 4f8a73b0d80af41a7bc9fe3f308da17d889588de Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 17:57:30 -0700
Subject: [PATCH 044/187] Maximize MC's main window before any tree geometry
work
MC restores its window position from the last interactive session; a
restored spot hanging off the monitor lets UIA read every tree row
while mouse-driven collapse strikes land on nothing (identical row
indexes round after round, then a 60 s timeout). WaitForAdminMain now
sets the UIA window pattern to Maximized before returning the window.
---
tests/ux/WebContent.UxTests/Harness/LoginFlow.cs | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index e8ab5f2..27033dd 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -264,7 +264,8 @@ public static Window WaitForSmartClientMain(ClientSession session, int timeoutSe
=> session.WaitForWindow(w => w.AutomationId == "_mainApplicationWindow", timeoutSeconds, "Smart Client main window");
public static Window WaitForAdminMain(ClientSession session, int timeoutSeconds = 120)
- => session.WaitForWindow(
+ {
+ var window = session.WaitForWindow(
w =>
{
var cls = w.ClassName;
@@ -276,6 +277,19 @@ public static Window WaitForAdminMain(ClientSession session, int timeoutSeconds
},
timeoutSeconds, "Management Client main window");
+ // MC restores its window position from the last interactive session - it can come back
+ // hanging off the monitor, where UIA still READS every tree row but mouse clicks land on
+ // nothing (collapse strikes become silent no-ops). Normalize before any geometry work.
+ try
+ {
+ if (window.Patterns.Window.IsSupported)
+ window.Patterns.Window.Pattern.SetWindowVisualState(FlaUI.Core.Definitions.WindowVisualState.Maximized);
+ Thread.Sleep(600);
+ }
+ catch { }
+ return window;
+ }
+
private static void SetValueOrType(AutomationElement element, string value)
{
try
From 45a71817ad3f4013a97bbb7d446968987564bca4 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 18:56:41 -0700
Subject: [PATCH 045/187] Probe the shell Save affordance broadly; never fight
an auto-login dialog
Two unattended-only failure legs from the 18:43 suite:
- Management Client edit path: SetValue demonstrably lands (the edited
allowlist survived MC restart via the close-modal sweep) yet the
Save wait found nothing. The locator demanded ControlType.Button;
a toolbar element crossing the WinForms/WPF UIA bridge can surface
as Text/Custom. Match any control type that supports Invoke, and
on failure dump a save/discard/revert name census to
mc-save-census.txt so the toolbar's real shape is recorded. Also
log the txtProxyTargets read-back immediately after SetValue.
- Smart Client login: a pre-filled dialog with a disabled Connect is
the client's own auto-login attempt; typing select-all+paste into
it raced that attempt. Detect the pre-filled state (remembered
username in the user combo) and wait 30 s hands-off first; post-
entry auto-login tolerances widened 10s->20s and 5s->10s.
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 57 ++++++++++++++++++-
.../Harness/ManagementClientUi.cs | 53 ++++++++++++++++-
.../ScProxyLoginProbeTests.cs | 2 +
3 files changed, 107 insertions(+), 5 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index 27033dd..f85766d 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -54,7 +54,23 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
// SUCCESS outcome, not a broken login - recognize it instead of faulting the probe.
try
{
- var serverBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox"));
+ // A pre-filled dialog with a DEAD Connect button is the client mid-auto-login: the
+ // fields hold the remembered credentials and Connect is disabled for the whole attempt.
+ // Typing into that dialog (select-all, paste) has raced and cancelled the attempt -
+ // leave it untouched and let the session window arrive.
+ var earlyConnect = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect"))
+ ?? loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton"));
+ if (earlyConnect != null && !TryIsEnabled(earlyConnect) && RememberedUserPresent(loginWindow, config))
+ {
+ LogEntry("dialog pre-filled with remembered user, Connect disabled - auto-login in progress; hands off 30 s");
+ if (WaitForConnected(session, 30))
+ {
+ LogEntry("auto-login completed while hands-off waiting; nothing to enter");
+ return;
+ }
+ }
+
+ var serverBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox"));
if (serverBox != null && !string.IsNullOrWhiteSpace(config.VmsAddress))
{
serverBox.Focus();
@@ -83,7 +99,7 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
// Never fight that dialog - give the auto-login room, then fall back to entering.
if (!WaitUntilEnabled(connect, 3))
{
- if (WaitForConnected(session, 10))
+ if (WaitForConnected(session, 20))
{
LogEntry("auto-login completed while waiting; leaving the dialog alone");
return;
@@ -92,7 +108,7 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con
Artifacts.Window(loginWindow, "login-filled-disabled");
DumpLoginTree(loginWindow, "login-tree-disabled.txt");
FocusAndType(session, loginWindow, passwordBox, config.VmsPassword);
- if (!WaitUntilEnabled(connect, 3) && !WaitForConnected(session, 5))
+ if (!WaitUntilEnabled(connect, 3) && !WaitForConnected(session, 10))
{
DumpLoginTree(loginWindow, "login-tree-disabled2.txt");
throw new InvalidOperationException(
@@ -127,6 +143,41 @@ private static bool WaitForConnected(ClientSession session, int seconds)
return false;
}
+ private static bool TryIsEnabled(AutomationElement element)
+ {
+ try { return element.IsEnabled; }
+ catch { return false; }
+ }
+
+ ///
+ /// True when the login dialog's user combo already carries the configured username -
+ /// the signature of the client's remembered-credentials auto-login attempt.
+ ///
+ private static bool RememberedUserPresent(Window loginWindow, UxConfig config)
+ {
+ try
+ {
+ var userCombo = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_userNameComboBox"));
+ var userBox = userCombo?.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox"));
+ var shown = userBox == null
+ ? (userCombo?.Name ?? string.Empty)
+ : (ReadBoxValue(userBox) ?? userBox.Name ?? string.Empty);
+ return !string.IsNullOrWhiteSpace(config.VmsUsername)
+ && shown.Contains(config.VmsUsername, StringComparison.OrdinalIgnoreCase);
+ }
+ catch { return false; }
+ }
+
+ private static string ReadBoxValue(AutomationElement box)
+ {
+ try
+ {
+ if (box.Patterns.Value.IsSupported) return box.Patterns.Value.Pattern.Value;
+ }
+ catch { }
+ return null;
+ }
+
private static readonly string[] PreferredTrustButtonIds =
{
"_btnContinue",
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index f1bd97a..014da10 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -1,3 +1,4 @@
+using System.Text;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
@@ -784,8 +785,7 @@ public static bool Save(ClientSession session, Window mainWindow, int timeoutSec
while (DateTime.UtcNow < deadline)
{
AcceptPendingModal(session, 1);
- var button = mainWindow.FindAllDescendants(cf => cf.ByName("Save"))
- .FirstOrDefault(b => b.ControlType == ControlType.Button && b.IsEnabled);
+ var button = FindSaveButton(mainWindow);
if (button != null)
{
try
@@ -816,9 +816,58 @@ public static bool Save(ClientSession session, Window mainWindow, int timeoutSec
}
Thread.Sleep(500);
}
+ DumpSaveCandidates(mainWindow);
return false;
}
+ ///
+ /// The shell's enabled "Save" affordance. Name match is any control type: the MC toolbar
+ /// renders Save as a button, but a toolbar element surfacing through the WinForms/WPF
+ /// bridge can report as Text or Custom, and insisting on ControlType.Button hid a live,
+ /// enabled Save from the harness.
+ ///
+ private static AutomationElement FindSaveButton(Window mainWindow)
+ {
+ return mainWindow.FindAllDescendants(cf => cf.ByName("Save"))
+ .FirstOrDefault(b => b.IsEnabled && b.Patterns.Invoke.IsSupported);
+ }
+
+ ///
+ /// Failure evidence for "Save never became enabled": every element in the shell whose name
+ /// mentions save/discard/revert, with control type and enabled state - so the next run
+ /// says WHAT the toolbar actually exposes instead of guessing.
+ ///
+ private static void DumpSaveCandidates(Window mainWindow)
+ {
+ try
+ {
+ var all = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Custom)
+ .Or(cf.ByControlType(ControlType.Button))
+ .Or(cf.ByControlType(ControlType.Text))
+ .Or(cf.ByControlType(ControlType.MenuItem))
+ .Or(cf.ByControlType(ControlType.Hyperlink)));
+ var hits = all.Where(e =>
+ {
+ var n = e.Name ?? string.Empty;
+ return n.IndexOf("save", StringComparison.OrdinalIgnoreCase) >= 0
+ || n.IndexOf("discard", StringComparison.OrdinalIgnoreCase) >= 0
+ || n.IndexOf("revert", StringComparison.OrdinalIgnoreCase) >= 0;
+ });
+ var sb = new StringBuilder();
+ sb.AppendLine($"save-census {DateTime.Now:HH:mm:ss} candidates={hits.Count()}");
+ foreach (var e in hits)
+ {
+ try
+ {
+ sb.AppendLine($" ct={e.ControlType} aid='{e.AutomationId}' name='{e.Name}' enabled={e.IsEnabled} invoke={e.Patterns.Invoke.IsSupported}");
+ }
+ catch (Exception ex) { sb.AppendLine(" element threw " + ex.GetType().Name); }
+ }
+ File.AppendAllText(Artifacts.PathOf("mc-save-census.txt"), sb + Environment.NewLine);
+ }
+ catch { }
+ }
+
/// True when the aggregate carries only "element not enabled" UIA failures (0x80040200).
private static bool IsStaleButton(AggregateException aggregate)
{
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 916e84b..a0e894f 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -366,6 +366,8 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
ManagementClientUi.EnsureCheckBox(mcMain, "chkProxyEnabled", true);
lines.Add(entry);
ManagementClientUi.SetValue(mcMain, "txtProxyTargets", string.Join("\n", lines));
+ var edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 5));
+ log.AppendLine($"proxy targets right after SetValue: '{edited}'");
if (!ManagementClientUi.Save(mc, mcMain, 20))
{
From 20b1530caae786114974d08455a32be8a098c03a Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 19:10:40 -0700
Subject: [PATCH 046/187] Edit proxy targets via keyboard paste, not
ValuePattern.SetValue
mc-save-attempts.txt caught the live shape: after SetValue the text
read back changed but the shell's Save stayed enabled=False for every
poll for 20 s - the WPF TextChanged -> ConfigurationChanged ->
InvokeChanged dirty chain never fired from the programmatic write in
a freshly-logged-in MC. The same SetValue against a long-idle MC DID
dirty the pane, and real keystrokes into the same box dirty it in
1.5 s. So: replace contents through focus + select-all + CF_UNICODETEXT
paste (the route login entry proved), verify by line read-back, fall
back to SetValue, and if Save is still cold after 6 s nudge the box
with END/SPACE/BACK - two genuine TextChanged events, contents
unchanged. Save()'s locator now logs every candidate's
control-type/enabled/invoke state per poll, and matches any invokable
control type (the toolbar Save renders as Button AND MenuItem, both
throwing on AutomationId).
---
.../WebContent.UxTests/Harness/LoginFlow.cs | 2 +-
.../Harness/ManagementClientUi.cs | 110 ++++++++++++++++--
.../ScProxyLoginProbeTests.cs | 30 ++++-
3 files changed, 129 insertions(+), 13 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
index f85766d..b549580 100644
--- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
+++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs
@@ -494,7 +494,7 @@ private static void EnterText(string text)
private const uint CF_UNICODETEXT = 13;
private const uint GHND = 0x42; // GMEM_MOVEABLE | GMEM_ZEROINIT
- private static bool TryClipboardSetText(string text)
+ internal static bool TryClipboardSetText(string text)
{
var handle = IntPtr.Zero;
try
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index 014da10..171cbd7 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -720,6 +720,58 @@ public static void SetValue(Window mainWindow, string automationId, string value
}
}
+ ///
+ /// Replace the full contents of a detail-pane Edit through the keyboard route the login
+ /// harness proved reliable: focus, select-all, CF_UNICODETEXT clipboard paste. The plain
+ /// ValuePattern.SetValue route writes the text but during live probe runs the shell never
+ /// enabled Save from it (mc-save-attempts.txt: enabled=False for every poll), while typing
+ /// into the same box dirtied it in ~1.5 s. Paste keeps the WPF TextChanged chain firing.
+ /// Returns false when the clipboard was unavailable (caller falls back to SetValue).
+ ///
+ public static bool PasteIntoControl(Window mainWindow, string automationId, string value)
+ {
+ var box = ExpectControl(mainWindow, automationId);
+ box.Focus();
+ System.Threading.Thread.Sleep(300);
+ if (!LoginFlow.TryClipboardSetText(value)) return false;
+ FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL,
+ FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A);
+ System.Threading.Thread.Sleep(150);
+ FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL,
+ FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_V);
+ System.Threading.Thread.Sleep(300);
+ return true;
+ }
+
+ /// True once the shell exposes an enabled, invokable "Save".
+ public static bool WaitForSaveEnabled(Window mainWindow, int seconds)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(seconds);
+ while (DateTime.UtcNow < deadline)
+ {
+ try { if (FindSaveButton(mainWindow, -1) != null) return true; }
+ catch { }
+ Thread.Sleep(400);
+ }
+ return false;
+ }
+
+ ///
+ /// Focus the Edit and press END, SPACE, BACK: a real keyboard round-trip through WPF input
+ /// that fires TextChanged even when a programmatic write left the shell's dirty state cold.
+ /// The box contents end up unchanged.
+ ///
+ public static void NudgeControlWithKeystroke(Window mainWindow, string automationId)
+ {
+ var box = ExpectControl(mainWindow, automationId, 10);
+ box.Focus();
+ System.Threading.Thread.Sleep(300);
+ FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.END);
+ FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.SPACE);
+ FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.BACK);
+ System.Threading.Thread.Sleep(300);
+ }
+
///
/// Drives a checkbox in the detail pane (WPF hosted control: AutomationId is the x:Name)
/// to the requested state using the UIA Toggle pattern.
@@ -782,10 +834,19 @@ public static bool Save(Window mainWindow, int timeoutSeconds = 20)
public static bool Save(ClientSession session, Window mainWindow, int timeoutSeconds = 20)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+ var attempt = 0;
while (DateTime.UtcNow < deadline)
{
AcceptPendingModal(session, 1);
- var button = FindSaveButton(mainWindow);
+ AutomationElement button = null;
+ try
+ {
+ button = FindSaveButton(mainWindow, attempt);
+ }
+ catch (Exception ex)
+ {
+ LogSaveAttempt(attempt, "locator threw " + ex.GetType().Name + ": " + ex.Message);
+ }
if (button != null)
{
try
@@ -814,6 +875,7 @@ public static bool Save(ClientSession session, Window mainWindow, int timeoutSec
AcceptPendingModal(session, 2);
return true;
}
+ attempt++;
Thread.Sleep(500);
}
DumpSaveCandidates(mainWindow);
@@ -824,12 +886,44 @@ public static bool Save(ClientSession session, Window mainWindow, int timeoutSec
/// The shell's enabled "Save" affordance. Name match is any control type: the MC toolbar
/// renders Save as a button, but a toolbar element surfacing through the WinForms/WPF
/// bridge can report as Text or Custom, and insisting on ControlType.Button hid a live,
- /// enabled Save from the harness.
+ /// enabled Save from the harness. Each poll records what every name-matching candidate
+ /// looked like, because "never became enabled" without the toolbar's actual state was a
+ /// guess-and-recheck loop across several runs.
///
- private static AutomationElement FindSaveButton(Window mainWindow)
+ private static AutomationElement FindSaveButton(Window mainWindow, int attempt)
+ {
+ var candidates = mainWindow.FindAllDescendants(cf => cf.ByName("Save"));
+ var described = new List();
+ AutomationElement chosen = null;
+ foreach (var b in candidates)
+ {
+ var ct = TryGet(() => b.ControlType.ToString());
+ var enabled = TryGet(() => b.IsEnabled.ToString());
+ var invoke = TryGet(() => b.Patterns.Invoke.IsSupported.ToString());
+ described.Add($"ct={ct} enabled={enabled} invoke={invoke}");
+ if (chosen == null && enabled == "True" && invoke == "True") chosen = b;
+ }
+ if (attempt >= 0 && attempt < 40)
+ {
+ LogSaveAttempt(attempt, described.Count == 0 ? "no ByName('Save') candidates" : string.Join(" | ", described));
+ }
+ return chosen;
+ }
+
+ private static string TryGet(Func read)
{
- return mainWindow.FindAllDescendants(cf => cf.ByName("Save"))
- .FirstOrDefault(b => b.IsEnabled && b.Patterns.Invoke.IsSupported);
+ try { return Convert.ToString(read()); }
+ catch (Exception ex) { return ""; }
+ }
+
+ private static void LogSaveAttempt(int attempt, string line)
+ {
+ try
+ {
+ File.AppendAllText(Artifacts.PathOf("mc-save-attempts.txt"),
+ $"{DateTime.Now:HH:mm:ss} attempt={attempt} {line}{Environment.NewLine}");
+ }
+ catch { }
}
///
@@ -857,11 +951,7 @@ private static void DumpSaveCandidates(Window mainWindow)
sb.AppendLine($"save-census {DateTime.Now:HH:mm:ss} candidates={hits.Count()}");
foreach (var e in hits)
{
- try
- {
- sb.AppendLine($" ct={e.ControlType} aid='{e.AutomationId}' name='{e.Name}' enabled={e.IsEnabled} invoke={e.Patterns.Invoke.IsSupported}");
- }
- catch (Exception ex) { sb.AppendLine(" element threw " + ex.GetType().Name); }
+ sb.AppendLine($" ct={TryGet(() => e.ControlType.ToString())} aid={TryGet(() => e.AutomationId)} name={TryGet(() => e.Name)} enabled={TryGet(() => e.IsEnabled)} invoke={TryGet(() => e.Patterns.Invoke.IsSupported)}");
}
File.AppendAllText(Artifacts.PathOf("mc-save-census.txt"), sb + Environment.NewLine);
}
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index a0e894f..935c0ca 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -365,9 +365,24 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
ManagementClientUi.EnsureCheckBox(mcMain, "chkProxyEnabled", true);
lines.Add(entry);
- ManagementClientUi.SetValue(mcMain, "txtProxyTargets", string.Join("\n", lines));
+ var joined = string.Join("\n", lines);
+
+ var pasted = ManagementClientUi.PasteIntoControl(mcMain, "txtProxyTargets", joined);
var edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 5));
- log.AppendLine($"proxy targets right after SetValue: '{edited}'");
+ if (!SameLines(joined, edited))
+ {
+ log.AppendLine($"paste route {(pasted ? "" : "(clipboard unavailable) ")}did not land (readback '{edited}'); ValuePattern fallback");
+ ManagementClientUi.SetValue(mcMain, "txtProxyTargets", joined);
+ edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 5));
+ }
+ log.AppendLine($"proxy targets right after edit: '{edited}'");
+
+ if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6))
+ {
+ log.AppendLine("Save still disabled after edit; keyboard-nudging the targets box");
+ ManagementClientUi.NudgeControlWithKeystroke(mcMain, "txtProxyTargets");
+ log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}");
+ }
if (!ManagementClientUi.Save(mc, mcMain, 20))
{
@@ -406,4 +421,15 @@ private static string ReadValue(AutomationElement element)
try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; }
catch { return element.Name; }
}
+
+ /// True when two multiline texts carry the same trimmed, non-empty lines.
+ private static bool SameLines(string expected, string actual)
+ {
+ string[] Norm(string s) => (s ?? string.Empty)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(l => l.Trim())
+ .Where(l => l.Length > 0)
+ .ToArray();
+ return Norm(expected).SequenceEqual(Norm(actual));
+ }
}
From 237f026648eb307d41d21a536e4e0dbd80b784d8 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 19:15:51 -0700
Subject: [PATCH 047/187] Record why SelectWorkspace saw no tabs
'neither a strip tab nor an overflow entry. Overflow saw: (empty)'
gave no way to tell an un-populated Smart Client from a locator
change. On failure, screenshot the main window and dump its window
list, every _textTextBlock strip label, and the first Text elements.
---
.../Harness/SmartClientUi.cs | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs b/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs
index d5f431d..27a18c4 100644
--- a/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs
@@ -1,3 +1,5 @@
+using System.IO;
+using System.Text;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
@@ -70,6 +72,25 @@ public static Window SelectWorkspace(Window mainWindow, ClientSession session, s
}
Thread.Sleep(1000);
}
+ Artifacts.Window(mainWindow, "sc-select-workspace-fail");
+ try
+ {
+ var dump = new StringBuilder();
+ dump.AppendLine($"select-workspace-fail for '{workspaceName}' {DateTime.Now:HH:mm:ss}");
+ foreach (var w in session.Windows())
+ {
+ string Safex(Func f) { try { return f() ?? ""; } catch { return ""; } }
+ dump.AppendLine($" window aid='{Safex(() => w.AutomationId)}' class='{Safex(() => w.ClassName)}' name='{Safex(() => w.Name)}'");
+ }
+ var blocks = mainWindow.FindAllDescendants(cf => cf.ByAutomationId("_textTextBlock"))
+ .Select(t => "'" + t.Name + "'");
+ var texts = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
+ .Take(40).Select(t => "'" + t.Name + "'");
+ dump.AppendLine(" _textTextBlock strip: " + string.Join(", ", blocks));
+ dump.AppendLine(" Text elements: " + string.Join(", ", texts));
+ File.AppendAllText(Artifacts.PathOf("sc-select-workspace-fail.txt"), dump + Environment.NewLine);
+ }
+ catch { }
throw new TimeoutException($"Workspace '{workspaceName}' is neither a strip tab nor an overflow entry. Overflow saw: {string.Join(", ", seen)}");
}
From 3a22cee05d330735ba9ae48fc762ab97ba68d95c Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 19:24:46 -0700
Subject: [PATCH 048/187] Gate every pane keystroke on verified focus; repair
the renamed item
The MIP.log tells the horror story of the 19:12 run: PasteIntoControl
typed Ctrl+A/Ctrl+V into whatever held focus (txtName, which the page
selects on load), so the first pasted line became the workspace's
Name, and the Save wrote it: 'ItemName.get - http://httpforever.com'.
The workspace item in the config is now mis-named - every later run
lost 'Demo Workspace' in the tree.
- PasteIntoControl/NudgeControlWithKeystroke now run through
FocusVerified: UIA FocusedElement must carry the expected
AutomationId before any key is sent; no typing into an unproven
control, ever.
- EnsureAllowlistedTarget adopts a lone mis-named child under the
workspaces node, retypes txtName (focus-gated) back to the expected
name, and REFUSES to Save when the pane Name does not read the
expected value - the guard that would have stopped the bad save.
- Dirty the pane via a chkProxyEnabled double Toggle flip when the
ValuePattern fallback writes text but the shell stays cold (matches
the observed live behaviour: programmatic TextBox writes never
enable Save in a fresh MC, Toggle/keystroke changes always do).
- Save() prefers the VISIBLE toolbar Save over the zero-rect
application-menu Save MenuItem.
---
.../Harness/ManagementClientUi.cs | 112 ++++++++++++++++--
.../ScProxyLoginProbeTests.cs | 51 +++++++-
2 files changed, 145 insertions(+), 18 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index 171cbd7..cb3ea64 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -722,17 +722,16 @@ public static void SetValue(Window mainWindow, string automationId, string value
///
/// Replace the full contents of a detail-pane Edit through the keyboard route the login
- /// harness proved reliable: focus, select-all, CF_UNICODETEXT clipboard paste. The plain
- /// ValuePattern.SetValue route writes the text but during live probe runs the shell never
- /// enabled Save from it (mc-save-attempts.txt: enabled=False for every poll), while typing
- /// into the same box dirtied it in ~1.5 s. Paste keeps the WPF TextChanged chain firing.
- /// Returns false when the clipboard was unavailable (caller falls back to SetValue).
+ /// harness proved reliable: focus (VERIFIED), select-all, CF_UNICODETEXT clipboard paste.
+ /// The verification is not decoration - an ungated run pasted the proxy-target list into
+ /// whatever field held focus (txtName), and the next Save renamed the workspace item to
+ /// 'http://httpforever.com' (MIP.log 19:12:31). Returns false when focus could not be
+ /// proven or the clipboard was unavailable; it NEVER types into an unproven control.
///
public static bool PasteIntoControl(Window mainWindow, string automationId, string value)
{
var box = ExpectControl(mainWindow, automationId);
- box.Focus();
- System.Threading.Thread.Sleep(300);
+ if (!FocusVerified(mainWindow, box, automationId)) return false;
if (!LoginFlow.TryClipboardSetText(value)) return false;
FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL,
FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A);
@@ -743,6 +742,50 @@ public static bool PasteIntoControl(Window mainWindow, string automationId, stri
return true;
}
+ ///
+ /// Focus an element and prove it took: UIA FocusedElement must carry the expected
+ /// AutomationId before any keystroke is sent. Retries the focus call once.
+ ///
+ public static bool FocusVerified(Window mainWindow, AutomationElement element, string expectedAutomationId)
+ {
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ try { element.Focus(); } catch { }
+ System.Threading.Thread.Sleep(300);
+ try
+ {
+ var focused = mainWindow.Automation.FocusedElement();
+ if (focused != null && string.Equals(focused.AutomationId, expectedAutomationId, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ catch { }
+ }
+ return false;
+ }
+
+ ///
+ /// Dirty the detail pane through the checkbox's Checked/Unchecked handlers: two Toggle
+ /// pattern flips leave the state as it was but fire the page's change events twice.
+ /// Programmatic TextBox writes proved not to raise the shell's dirty chain in a freshly
+ /// logged-in MC, while Toggle-driven changes do.
+ ///
+ public static bool DirtyPaneViaCheckbox(Window mainWindow, string automationId)
+ {
+ try
+ {
+ var box = ExpectControl(mainWindow, automationId, 10);
+ if (!box.Patterns.Toggle.IsSupported) return false;
+ var toggle = box.Patterns.Toggle.Pattern;
+ var wasOn = toggle.ToggleState == FlaUI.Core.Definitions.ToggleState.On;
+ toggle.Toggle();
+ System.Threading.Thread.Sleep(300);
+ toggle.Toggle();
+ System.Threading.Thread.Sleep(300);
+ return toggle.ToggleState == (wasOn ? FlaUI.Core.Definitions.ToggleState.On : FlaUI.Core.Definitions.ToggleState.Off);
+ }
+ catch { return false; }
+ }
+
/// True once the shell exposes an enabled, invokable "Save".
public static bool WaitForSaveEnabled(Window mainWindow, int seconds)
{
@@ -757,21 +800,59 @@ public static bool WaitForSaveEnabled(Window mainWindow, int seconds)
}
///
- /// Focus the Edit and press END, SPACE, BACK: a real keyboard round-trip through WPF input
- /// that fires TextChanged even when a programmatic write left the shell's dirty state cold.
- /// The box contents end up unchanged.
+ /// Focus the Edit (verified) and press END, SPACE, BACK: a real keyboard round-trip through
+ /// WPF input that fires TextChanged even when a programmatic write left the shell's dirty
+ /// state cold. The box contents end up unchanged; nothing is typed without the focus gate.
///
public static void NudgeControlWithKeystroke(Window mainWindow, string automationId)
{
var box = ExpectControl(mainWindow, automationId, 10);
- box.Focus();
- System.Threading.Thread.Sleep(300);
+ if (!FocusVerified(mainWindow, box, automationId)) return;
FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.END);
FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.SPACE);
FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.BACK);
System.Threading.Thread.Sleep(300);
}
+ ///
+ /// Name of the single workspace item under the workspaces node when the expected name is
+ /// gone - an item renamed by a stray paste still shows up in the tree under its bad name.
+ /// Null when the node has no deeper-indented child at all.
+ ///
+ public static string OrphanWorkspaceName(Window mainWindow, string workspacesNodeName)
+ {
+ var items = RealizedTreeItems(mainWindow);
+ var node = items.FirstOrDefault(t => string.Equals(t.Name, workspacesNodeName, StringComparison.OrdinalIgnoreCase));
+ if (node == null) return null;
+ var idx = items.IndexOf(node);
+ for (var i = idx + 1; i < items.Count; i++)
+ {
+ try
+ {
+ if (items[i].BoundingRectangle.X > node.BoundingRectangle.X) return items[i].Name;
+ break;
+ }
+ catch { break; }
+ }
+ return null;
+ }
+
+ ///
+ /// Re-type the pane's Name field (focus-verified keyboard only): select-all, then plain
+ /// ASCII typing. Throws when focus cannot be proven - the field is never typed blind.
+ ///
+ public static void RenameWorkspacePane(Window mainWindow, string newName)
+ {
+ var box = ExpectControl(mainWindow, "txtName", 15);
+ if (!FocusVerified(mainWindow, box, "txtName"))
+ throw new InvalidOperationException("Cannot verify focus on txtName; refusing to type the workspace rename.");
+ FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL,
+ FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A);
+ System.Threading.Thread.Sleep(150);
+ FlaUI.Core.Input.Keyboard.Type(newName);
+ System.Threading.Thread.Sleep(300);
+ }
+
///
/// Drives a checkbox in the detail pane (WPF hosted control: AutomationId is the x:Name)
/// to the requested state using the UIA Toggle pattern.
@@ -901,7 +982,12 @@ private static AutomationElement FindSaveButton(Window mainWindow, int attempt)
var enabled = TryGet(() => b.IsEnabled.ToString());
var invoke = TryGet(() => b.Patterns.Invoke.IsSupported.ToString());
described.Add($"ct={ct} enabled={enabled} invoke={invoke}");
- if (chosen == null && enabled == "True" && invoke == "True") chosen = b;
+ if (enabled != "True" || invoke != "True") continue;
+ // Prefer the VISIBLE toolbar affordance; an application-menu 'Save' MenuItem
+ // (zero rect) enumerates first but is the riskier click target.
+ var visible = TryGet(() => b.BoundingRectangle.Width.ToString()) != "0";
+ var chosenVisible = chosen != null && TryGet(() => chosen.BoundingRectangle.Width.ToString()) != "0";
+ if (chosen == null || (visible && !chosenVisible)) chosen = b;
}
if (attempt >= 0 && attempt < 40)
{
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 935c0ca..8873b55 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -338,8 +338,36 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
// rows the next one needs, so no blind walking or long sleeps are involved.
ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName });
ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName);
- ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName);
- ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName);
+
+ // A previous bad Save once renamed the item (txtName corrupted by an ungated paste -
+ // MIP.log 'ItemName.get ... http://httpforever.com'). If the expected child is gone but
+ // the workspaces node has a lone child, adopt it and repair its name before touching
+ // anything else.
+ var workspaceItem = config.McWorkspaceName;
+ try
+ {
+ ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName);
+ }
+ catch (InvalidOperationException)
+ {
+ // The last toggle strategy may have left the node collapsed: one more expansion
+ // pass with no child-name assertion, then look for the lone child.
+ try { ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, new string[0]); }
+ catch (InvalidOperationException) { }
+ var orphan = ManagementClientUi.OrphanWorkspaceName(mcMain, ManagementClientUi.WorkspacesNodeName);
+ if (orphan == null) throw;
+ log.AppendLine($"workspace item is named '{orphan}', not '{config.McWorkspaceName}' - adopting and renaming");
+ workspaceItem = orphan;
+ }
+ ManagementClientUi.SelectTreeItem(mcMain, workspaceItem);
+
+ var nameBox = ManagementClientUi.ExpectControl(mcMain, "txtName", 15);
+ var paneName = ReadValue(nameBox);
+ if (!string.Equals(paneName, config.McWorkspaceName, StringComparison.Ordinal))
+ {
+ log.AppendLine($"pane Name field reads '{paneName}'; retyping '{config.McWorkspaceName}'");
+ ManagementClientUi.RenameWorkspacePane(mcMain, config.McWorkspaceName);
+ }
var targetsBox = ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 30);
var current = ReadValue(targetsBox);
@@ -379,11 +407,24 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6))
{
- log.AppendLine("Save still disabled after edit; keyboard-nudging the targets box");
- ManagementClientUi.NudgeControlWithKeystroke(mcMain, "txtProxyTargets");
- log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}");
+ log.AppendLine("Save still disabled after edit; flipping chkProxyEnabled twice to dirty the pane");
+ ManagementClientUi.DirtyPaneViaCheckbox(mcMain, "chkProxyEnabled");
+ if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6))
+ {
+ log.AppendLine("Save still disabled after checkbox flip; keyboard-nudging the targets box");
+ ManagementClientUi.NudgeControlWithKeystroke(mcMain, "txtProxyTargets");
+ log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}");
+ }
}
+ // Guard before the Save: the shell writes EVERY pane field, and one bad run already
+ // saved a corrupted Name ('ItemName.get ... http://httpforever.com'). Never re-save
+ // a pane whose Name field does not read the workspace name.
+ var paneNameNow = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtName", 5));
+ if (!string.Equals(paneNameNow, config.McWorkspaceName, StringComparison.Ordinal))
+ throw new InvalidOperationException(
+ $"Refusing to Save: the Name field reads '{paneNameNow}', expected '{config.McWorkspaceName}'.");
+
if (!ManagementClientUi.Save(mc, mcMain, 20))
{
throw new InvalidOperationException($"Save never became enabled after adding '{entry}' to the proxy targets.");
From 73e97204508de56e2d5bf859a0cf39bcb2b7da91 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 23:06:21 -0700
Subject: [PATCH 049/187] Dump the realized tree rows when the workspace child
cannot be found
---
.../Harness/ManagementClientUi.cs | 18 ++++++++++++++++++
.../ScProxyLoginProbeTests.cs | 2 ++
2 files changed, 20 insertions(+)
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index cb3ea64..192ea21 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -837,6 +837,24 @@ public static string OrphanWorkspaceName(Window mainWindow, string workspacesNod
return null;
}
+ /// Every realized tree row (name, indent X, Y) - so a "no children appeared"
+ /// failure says what the tree ACTUALLY shows.
+ public static void DumpWorkspaceChildren(Window mainWindow, StringBuilder log)
+ {
+ try
+ {
+ var items = RealizedTreeItems(mainWindow);
+ var idx = items.FindIndex(t => string.Equals(t.Name, WorkspacesNodeName, StringComparison.OrdinalIgnoreCase));
+ log.AppendLine($"tree rows={items.Count}, workspaces-node idx={(idx < 0 ? "ABSENT" : idx.ToString())}");
+ foreach (var t in items)
+ {
+ var r = t.BoundingRectangle;
+ log.AppendLine($" '{t.Name}' x={r.X} y={r.Y}");
+ }
+ }
+ catch (Exception ex) { log.AppendLine("tree dump threw " + ex.GetType().Name); }
+ }
+
///
/// Re-type the pane's Name field (focus-verified keyboard only): select-all, then plain
/// ASCII typing. Throws when focus cannot be proven - the field is never typed blind.
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 8873b55..369c051 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -350,10 +350,12 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
}
catch (InvalidOperationException)
{
+ ManagementClientUi.DumpWorkspaceChildren(mcMain, log);
// The last toggle strategy may have left the node collapsed: one more expansion
// pass with no child-name assertion, then look for the lone child.
try { ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, new string[0]); }
catch (InvalidOperationException) { }
+ ManagementClientUi.DumpWorkspaceChildren(mcMain, log);
var orphan = ManagementClientUi.OrphanWorkspaceName(mcMain, ManagementClientUi.WorkspacesNodeName);
if (orphan == null) throw;
log.AppendLine($"workspace item is named '{orphan}', not '{config.McWorkspaceName}' - adopting and renaming");
From 1821d103766a8aa420d615071cb96cb7103fc1c3 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 23:11:02 -0700
Subject: [PATCH 050/187] Recreate a destroyed workspace item through the shell
New menu
The 23:06 tree dump ended the question: the workspaces node had ZERO
children - the 19:12 corrupted Save did not just rename the item, the
item is gone from the config. EnsureAllowlistedTarget now right-clicks
the kind node, logs every context-menu caption it finds (the New
entry's exact label is recorded, not guessed), invokes the New* entry,
and fills the resulting pane: focus-gated rename to the expected name,
proxy checkbox, targets, guarded Save.
---
.../Harness/ManagementClientUi.cs | 43 +++++++++++++++++++
.../ScProxyLoginProbeTests.cs | 21 +++++++--
2 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index 192ea21..a1b6ada 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -855,6 +855,49 @@ public static void DumpWorkspaceChildren(Window mainWindow, StringBuilder log)
catch (Exception ex) { log.AppendLine("tree dump threw " + ex.GetType().Name); }
}
+ ///
+ /// Right-clicks a tree node and invokes the shell's New-item menu entry (whatever it is
+ /// called - every menu item is logged so the caption is recorded, not guessed). Used to
+ /// recreate the workspace item after a bad Save destroyed it.
+ ///
+ public static bool NewItemUnderNode(Window mainWindow, ClientSession session, string nodeName, StringBuilder log)
+ {
+ var items = RealizedTreeItems(mainWindow);
+ var node = items.FirstOrDefault(t => string.Equals(t.Name, nodeName, StringComparison.OrdinalIgnoreCase));
+ if (node == null) return false;
+ var r = node.BoundingRectangle;
+ FlaUI.Core.Input.Mouse.RightClick(new System.Drawing.Point(r.X + 30, r.Y + r.Height / 2));
+ Thread.Sleep(1500);
+
+ var scopes = new List { mainWindow };
+ try { scopes.AddRange(session.Windows()); } catch { }
+ AutomationElement newItem = null;
+ foreach (var w in scopes)
+ {
+ try
+ {
+ foreach (var mi in w.FindAllDescendants(cf => cf.ByControlType(ControlType.MenuItem)))
+ {
+ var n = mi.Name ?? string.Empty;
+ if (n.Length == 0) continue;
+ log.AppendLine($"ctx-menu '{n}' enabled={TryGet(() => mi.IsEnabled.ToString())}");
+ if (newItem == null && n.StartsWith("New", StringComparison.OrdinalIgnoreCase)) newItem = mi;
+ }
+ }
+ catch { }
+ }
+ if (newItem == null)
+ {
+ log.AppendLine("no New* context-menu entry found");
+ try { FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.ESCAPE); } catch { }
+ return false;
+ }
+ try { newItem.Patterns.Invoke.Pattern.Invoke(); }
+ catch { try { newItem.Click(); } catch { } }
+ Thread.Sleep(2500);
+ return true;
+ }
+
///
/// Re-type the pane's Name field (focus-verified keyboard only): select-all, then plain
/// ASCII typing. Throws when focus cannot be proven - the field is never typed blind.
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 369c051..799813f 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -344,6 +344,7 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
// the workspaces node has a lone child, adopt it and repair its name before touching
// anything else.
var workspaceItem = config.McWorkspaceName;
+ var paneReady = false;
try
{
ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName);
@@ -357,11 +358,23 @@ private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.
catch (InvalidOperationException) { }
ManagementClientUi.DumpWorkspaceChildren(mcMain, log);
var orphan = ManagementClientUi.OrphanWorkspaceName(mcMain, ManagementClientUi.WorkspacesNodeName);
- if (orphan == null) throw;
- log.AppendLine($"workspace item is named '{orphan}', not '{config.McWorkspaceName}' - adopting and renaming");
- workspaceItem = orphan;
+ if (orphan != null)
+ {
+ log.AppendLine($"workspace item is named '{orphan}', not '{config.McWorkspaceName}' - adopting and renaming");
+ workspaceItem = orphan;
+ }
+ else
+ {
+ log.AppendLine("workspaces node has NO children - recreating the item via the shell New menu");
+ if (!ManagementClientUi.NewItemUnderNode(mcMain, mc, ManagementClientUi.WorkspacesNodeName, log))
+ throw new InvalidOperationException(
+ $"Workspace '{config.McWorkspaceName}' is gone from the config and the shell New menu was unavailable.");
+ ManagementClientUi.AcceptPendingModal(mc, 5); // a name prompt, if the shell shows one
+ paneReady = true; // the New flow leaves the pane showing the fresh item
+ log.AppendLine("created new workspace item; filling the pane");
+ }
}
- ManagementClientUi.SelectTreeItem(mcMain, workspaceItem);
+ if (!paneReady) ManagementClientUi.SelectTreeItem(mcMain, workspaceItem);
var nameBox = ManagementClientUi.ExpectControl(mcMain, "txtName", 15);
var paneName = ReadValue(nameBox);
From f610ef36114b2dd96e3ba1d5fe2d3f38e9ae0fbe Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 23:14:15 -0700
Subject: [PATCH 051/187] Create the workspace via the menu bar 'Add New ...'
entry
The tree right-click produced no popup menu (the census saw only the
menu bar) - but the menu bar itself carries Action > 'Add New ...'.
Invoke that, dump every window/dialog that appears (buttons, edits,
texts), and pick our kind out of any kind-picker before accepting.
---
.../Harness/ManagementClientUi.cs | 71 ++++++++++++-------
1 file changed, 44 insertions(+), 27 deletions(-)
diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
index a1b6ada..c453576 100644
--- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
+++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs
@@ -856,45 +856,62 @@ public static void DumpWorkspaceChildren(Window mainWindow, StringBuilder log)
}
///
- /// Right-clicks a tree node and invokes the shell's New-item menu entry (whatever it is
- /// called - every menu item is logged so the caption is recorded, not guessed). Used to
- /// recreate the workspace item after a bad Save destroyed it.
+ /// Create a new item of the kind via the shell's 'Add New ...' menu entry (the tree
+ /// right-click opens no usable context menu on this shell - the menu bar census proved
+ /// 'Add New ...' is the real affordance). Every window/dialog that shows up afterwards is
+ /// logged so the follow-up dialog's shape is recorded, not guessed.
///
public static bool NewItemUnderNode(Window mainWindow, ClientSession session, string nodeName, StringBuilder log)
{
- var items = RealizedTreeItems(mainWindow);
- var node = items.FirstOrDefault(t => string.Equals(t.Name, nodeName, StringComparison.OrdinalIgnoreCase));
- if (node == null) return false;
- var r = node.BoundingRectangle;
- FlaUI.Core.Input.Mouse.RightClick(new System.Drawing.Point(r.X + 30, r.Y + r.Height / 2));
- Thread.Sleep(1500);
+ var addNew = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.MenuItem))
+ .FirstOrDefault(mi => (mi.Name ?? string.Empty).IndexOf("add new", StringComparison.OrdinalIgnoreCase) >= 0);
+ if (addNew == null)
+ {
+ log.AppendLine("no 'Add New ...' menu-bar entry found");
+ return false;
+ }
+ try { addNew.Patterns.Invoke.Pattern.Invoke(); }
+ catch (Exception ex) { log.AppendLine("Add New invoke threw " + ex.GetType().Name); try { addNew.Click(); } catch { } }
+ Thread.Sleep(2500);
- var scopes = new List { mainWindow };
- try { scopes.AddRange(session.Windows()); } catch { }
- AutomationElement newItem = null;
- foreach (var w in scopes)
+ // What did it open? Log windows, their edits/buttons, and any menu items left over.
+ try
{
- try
+ foreach (var w in session.Windows())
{
- foreach (var mi in w.FindAllDescendants(cf => cf.ByControlType(ControlType.MenuItem)))
+ string Safex(Func f) { try { return f() ?? ""; } catch { return ""; } }
+ log.AppendLine($"after AddNew window aid='{Safex(() => w.AutomationId)}' name='{Safex(() => w.Name)}' class='{Safex(() => w.ClassName)}'");
+ try
{
- var n = mi.Name ?? string.Empty;
- if (n.Length == 0) continue;
- log.AppendLine($"ctx-menu '{n}' enabled={TryGet(() => mi.IsEnabled.ToString())}");
- if (newItem == null && n.StartsWith("New", StringComparison.OrdinalIgnoreCase)) newItem = mi;
+ foreach (var b in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)).Take(10))
+ log.AppendLine($" edit aid='{Safex(() => b.AutomationId)}' name='{Safex(() => b.Name)}'");
+ foreach (var b in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)).Take(15))
+ log.AppendLine($" button aid='{Safex(() => b.AutomationId)}' name='{Safex(() => b.Name)}'");
+ foreach (var b in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)).Take(15))
+ log.AppendLine($" text name='{Safex(() => b.Name)}'");
}
+ catch { }
}
- catch { }
}
- if (newItem == null)
+ catch (Exception ex) { log.AppendLine("AddNew aftermath dump threw " + ex.GetType().Name); }
+
+ // If the shell opened a kind-picker, select our kind before accepting the dialog.
+ try
{
- log.AppendLine("no New* context-menu entry found");
- try { FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.ESCAPE); } catch { }
- return false;
+ foreach (var w in session.Windows())
+ {
+ foreach (var kind in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Button))
+ .Concat(w.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)))
+ .Where(e => (e.Name ?? string.Empty).IndexOf("Web Content", StringComparison.OrdinalIgnoreCase) >= 0))
+ {
+ log.AppendLine($"AddNew picker: choosing '{kind.Name}'");
+ try { kind.Patterns.Invoke.Pattern.Invoke(); } catch { try { kind.Click(); } catch { } }
+ Thread.Sleep(1500);
+ break;
+ }
+ }
}
- try { newItem.Patterns.Invoke.Pattern.Invoke(); }
- catch { try { newItem.Click(); } catch { } }
- Thread.Sleep(2500);
+ catch { }
return true;
}
From 4aa8dca437f75439b68c8072b956ad6172a1343f Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sat, 31 Oct 2026 23:52:35 -0700
Subject: [PATCH 052/187] Share the Management Client provisioning across
proxied probes
The fetch probe assumed the workspace keeps its httpforever allowlist entry
forever; the recreated Demo Workspace proved that wrong (403 row). All four
ProbeProxied facts now run the same MC phase: repair-ladder (adopt mis-named
lone child, recreate via Add New), focus-gated keyboard edits, pre-Save Name
guard, verified readback, and origin-replacing entry semantics.
---
.../Harness/ProxyTargetProvisioning.cs | 185 ++++++++++++++++++
.../ScProxyFetchProbeTests.cs | 45 +++--
.../ScProxyLoginProbeTests.cs | 177 +----------------
3 files changed, 217 insertions(+), 190 deletions(-)
create mode 100644 tests/ux/WebContent.UxTests/Harness/ProxyTargetProvisioning.cs
diff --git a/tests/ux/WebContent.UxTests/Harness/ProxyTargetProvisioning.cs b/tests/ux/WebContent.UxTests/Harness/ProxyTargetProvisioning.cs
new file mode 100644
index 0000000..4de5743
--- /dev/null
+++ b/tests/ux/WebContent.UxTests/Harness/ProxyTargetProvisioning.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using FlaUI.Core.AutomationElements;
+
+namespace WebContent.UxTests.Harness;
+
+///
+/// Shared Management Client phase for every proxied-target probe: make sure the workspace
+/// proxy target list carries exactly one line for the probe origin - the entry as given,
+/// replacing any older line for the same origin so a previous run's flag state never leaks
+/// into this row's verdict. Runs before the Smart Client so the fetched configuration
+/// carries the new allowlist. Includes the self-repair discovered the hard way: adopting a
+/// mis-named item, recreating a destroyed one (Add New), focus-gated keyboard edits, the
+/// pre-Save Name guard, and verified readback.
+///
+public static class ProxyTargetProvisioning
+{
+ public static void Ensure(UxConfig config, string entry, StringBuilder log)
+ {
+ ProcessKiller.KillClients("VideoOS.Administration");
+ using var mc = ClientSession.Launch(config.AdminClientExe);
+ var mcLogin = LoginFlow.WaitForLoginWindow(mc);
+ LoginFlow.Login(mc, mcLogin, config);
+ LoginFlow.AcceptCertificateTrust(mc);
+ var mcMain = LoginFlow.WaitForAdminMain(mc);
+ ManagementClientUi.CloseStrayLoginDialogs(mc);
+ ManagementClientUi.WaitForTreeReady(mcMain);
+
+ // Canonical starting state (fast double-click collapse, no keyboard), then walk the
+ // known chain top-down (enumerate rows + click + wheel scroll): each step realizes the
+ // rows the next one needs, so no blind walking or long sleeps are involved.
+ ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName });
+ ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName);
+
+ // A previous bad Save once renamed the item (txtName corrupted by an ungated paste -
+ // MIP.log 'ItemName.get ... http://httpforever.com'), and later evidence showed the
+ // item gone entirely. Adopt a mis-named lone child, or recreate via the shell New
+ // affordance, before touching anything else.
+ var workspaceItem = config.McWorkspaceName;
+ var paneReady = false;
+ try
+ {
+ ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName);
+ }
+ catch (InvalidOperationException)
+ {
+ ManagementClientUi.DumpWorkspaceChildren(mcMain, log);
+ // The last toggle strategy may have left the node collapsed: one more expansion
+ // pass with no child-name assertion, then look for the lone child.
+ try { ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, new string[0]); }
+ catch (InvalidOperationException) { }
+ ManagementClientUi.DumpWorkspaceChildren(mcMain, log);
+ var orphan = ManagementClientUi.OrphanWorkspaceName(mcMain, ManagementClientUi.WorkspacesNodeName);
+ if (orphan != null)
+ {
+ log.AppendLine($"workspace item is named '{orphan}', not '{config.McWorkspaceName}' - adopting and renaming");
+ workspaceItem = orphan;
+ }
+ else
+ {
+ log.AppendLine("workspaces node has NO children - recreating the item via the shell New menu");
+ if (!ManagementClientUi.NewItemUnderNode(mcMain, mc, ManagementClientUi.WorkspacesNodeName, log))
+ throw new InvalidOperationException(
+ $"Workspace '{config.McWorkspaceName}' is gone from the config and the shell New menu was unavailable.");
+ ManagementClientUi.AcceptPendingModal(mc, 5); // a name prompt, if the shell shows one
+ paneReady = true; // the New flow leaves the pane showing the fresh item
+ log.AppendLine("created new workspace item; filling the pane");
+ }
+ }
+ if (!paneReady) ManagementClientUi.SelectTreeItem(mcMain, workspaceItem);
+
+ var nameBox = ManagementClientUi.ExpectControl(mcMain, "txtName", 15);
+ var paneName = ReadValue(nameBox);
+ if (!string.Equals(paneName, config.McWorkspaceName, StringComparison.Ordinal))
+ {
+ log.AppendLine($"pane Name field reads '{paneName}'; retyping '{config.McWorkspaceName}'");
+ ManagementClientUi.RenameWorkspacePane(mcMain, config.McWorkspaceName);
+ }
+
+ var targetsBox = ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 30);
+ var current = ReadValue(targetsBox);
+ log.AppendLine($"proxy targets before: '{current}'");
+
+ var lines = (current ?? string.Empty)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(line => line.Trim())
+ .Where(line => line.Length > 0)
+ .ToList();
+
+ if (lines.Any(line => string.Equals(line, entry, StringComparison.OrdinalIgnoreCase)))
+ {
+ log.AppendLine($"entry '{entry}' already allowlisted; no Management Client edit needed");
+ Retire(mc, mcMain);
+ return;
+ }
+
+ // Same origin with a different flag state is a contradictory entry: exactly one line
+ // decides the origin's TLS posture.
+ var originToken = entry.Split(' ')[0];
+ lines.RemoveAll(line => line.Split(' ')[0].Equals(originToken, StringComparison.OrdinalIgnoreCase));
+
+ ManagementClientUi.EnsureCheckBox(mcMain, "chkProxyEnabled", true);
+ lines.Add(entry);
+ var joined = string.Join("\n", lines);
+
+ var pasted = ManagementClientUi.PasteIntoControl(mcMain, "txtProxyTargets", joined);
+ var edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 5));
+ if (!SameLines(joined, edited))
+ {
+ log.AppendLine($"paste route {(pasted ? "" : "(clipboard unavailable) ")}did not land (readback '{edited}'); ValuePattern fallback");
+ ManagementClientUi.SetValue(mcMain, "txtProxyTargets", joined);
+ edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 5));
+ }
+ log.AppendLine($"proxy targets right after edit: '{edited}'");
+
+ if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6))
+ {
+ log.AppendLine("Save still disabled after edit; flipping chkProxyEnabled twice to dirty the pane");
+ ManagementClientUi.DirtyPaneViaCheckbox(mcMain, "chkProxyEnabled");
+ if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6))
+ {
+ log.AppendLine("Save still disabled after checkbox flip; keyboard-nudging the targets box");
+ ManagementClientUi.NudgeControlWithKeystroke(mcMain, "txtProxyTargets");
+ log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}");
+ }
+ }
+
+ // Guard before the Save: the shell writes EVERY pane field, and one bad run already
+ // saved a corrupted Name ('ItemName.get ... http://httpforever.com'). Never re-save
+ // a pane whose Name field does not read the workspace name.
+ var paneNameNow = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtName", 5));
+ if (!string.Equals(paneNameNow, config.McWorkspaceName, StringComparison.Ordinal))
+ throw new InvalidOperationException(
+ $"Refusing to Save: the Name field reads '{paneNameNow}', expected '{config.McWorkspaceName}'.");
+
+ if (!ManagementClientUi.Save(mc, mcMain, 20))
+ {
+ throw new InvalidOperationException($"Save never became enabled after adding '{entry}' to the proxy targets.");
+ }
+
+ // Read the pane back: a save the shell silently discarded (or a confirmation whose
+ // No/Cancel path was taken) must fail here, not strand the Smart Client phase with a
+ // stale allowlist.
+ Thread.Sleep(1500);
+ var after = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 15));
+ if (after == null || !after.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Any(line => line.Trim().Equals(entry, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException($"After Save, the proxy targets read back '{after}' — '{entry}' did not stick.");
+ }
+
+ log.AppendLine($"allowlisted '{entry}' and saved (verified readback)");
+ Retire(mc, mcMain);
+ }
+
+ ///
+ /// Closes Management Client after its phase: an MC left open can hold a pending
+ /// "Save changes?" modal that steals focus and blocks UIA for the Smart Client phase
+ /// (the #56 hang). Close politely, sweep any confirmation it raises, then guarantee exit.
+ ///
+ private static void Retire(ClientSession mc, Window mcMain)
+ {
+ try { mcMain.Close(); } catch { }
+ ManagementClientUi.AcceptPendingModal(mc, 5);
+ ProcessKiller.KillClients("VideoOS.Administration");
+ }
+
+ private static string ReadValue(AutomationElement element)
+ {
+ try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; }
+ catch { return element.Name; }
+ }
+
+ private static bool SameLines(string expected, string actual)
+ {
+ string[] Norm(string s) => (s ?? string.Empty)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(l => l.Trim())
+ .Where(l => l.Length > 0)
+ .ToArray();
+ return Norm(expected).SequenceEqual(Norm(actual));
+ }
+}
diff --git a/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
index 36b80b3..ad244bb 100644
--- a/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs
@@ -18,6 +18,14 @@ public class ScProxyFetchProbeTests
{
private const string FetchUrl = "http://httpforever.com/";
+ ///
+ /// The origin form of as the workspace allowlist stores it: no
+ /// trailing slash. The fetch probe depends on this entry; the login probes learned the
+ /// hard way that the entry must be (re)provisioned, not assumed, after the workspace
+ /// item was recreated.
+ ///
+ private const string AllowlistEntry = "http://httpforever.com";
+
private readonly ITestOutputHelper _output;
public ScProxyFetchProbeTests(ITestOutputHelper output) => _output = output;
@@ -30,27 +38,35 @@ public async Task ProbeProxiedWorkspaceBrowserFetch()
Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe"));
_output.WriteLine($"Artifacts: {Artifacts.RunDirectory}");
- CdpConnector.CleanStalePortFiles(config.WebView2DataRoot);
- ProcessKiller.KillClients("Client");
- ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
-
- using var sc = ClientSession.Launch(config.SmartClientExe);
- var login = LoginFlow.WaitForLoginWindow(sc);
- LoginFlow.Login(sc, login, config);
- LoginFlow.AcceptCertificateTrust(sc);
- var main = LoginFlow.WaitForSmartClientMain(sc);
- SmartClientUi.Maximize(main);
- Thread.Sleep(1500);
- main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow");
-
var log = new System.Text.StringBuilder();
string failure = null;
string title = null;
var bodyChars = -1;
var directReachedTarget = false;
var blockedControl = Environment.GetEnvironmentVariable("WEBCONTENT_SC1_BLOCKED") == "1";
+ ClientSession sc = null;
try
{
+ // Phase 1 (Management Client): provision the allowlist entry this probe depends
+ // on before the Smart Client logs in. Assuming the workspace keeps the entry
+ // forever is what turned this row red (403) after the Demo Workspace item was
+ // recreated without it.
+ ProxyTargetProvisioning.Ensure(config, AllowlistEntry, log);
+
+ // Phase 2: fresh Smart Client so it fetches the updated configuration.
+ CdpConnector.CleanStalePortFiles(config.WebView2DataRoot);
+ ProcessKiller.KillClients("Client");
+ ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
+
+ sc = ClientSession.Launch(config.SmartClientExe);
+ var login = LoginFlow.WaitForLoginWindow(sc);
+ LoginFlow.Login(sc, login, config);
+ LoginFlow.AcceptCertificateTrust(sc);
+ var main = LoginFlow.WaitForSmartClientMain(sc);
+ SmartClientUi.Maximize(main);
+ Thread.Sleep(1500);
+ main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow");
+
main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName);
var item = SmartClientUi.WaitForMipViewItem(main);
Assert.NotNull(item);
@@ -119,7 +135,7 @@ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
{
failure = $"{ex.GetType().Name}: {ex.Message}";
log.AppendLine($"probe failed: {failure}");
- var fresh = sc.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow");
+ var fresh = sc?.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow");
if (fresh != null)
{
Artifacts.Window(fresh, "sc-proxy-probe-failure");
@@ -131,6 +147,7 @@ await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions
_output.WriteLine(log.ToString());
ProcessKiller.KillClients("Client");
ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData");
+ sc?.Dispose();
}
// The probe goes RED: evidence in the artifacts file is for humans, but a proxy that
diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
index 799813f..3c2f596 100644
--- a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
+++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs
@@ -1,5 +1,3 @@
-using FlaUI.Core;
-using FlaUI.Core.AutomationElements;
using WebContent.UxTests.Harness;
using Xunit;
using Xunit.Abstractions;
@@ -164,7 +162,7 @@ private async Task RunLoginProbeAsync(
// Phase 1: the workspace proxy allowlist must carry the login origin before the
// Smart Client logs in — a target the fetcher refuses is a NotAllowed frame, not a
// login page.
- EnsureAllowlistedTarget(config, allowlistEntry, log);
+ ProxyTargetProvisioning.Ensure(config, allowlistEntry, log);
// Phase 2: serve the vendor site on THIS machine; the Event Server proves server-side
// reachability by fetching it from the VMS network while the browser never goes direct.
@@ -315,177 +313,4 @@ private static IReadOnlyList Snapshot(LoginTestServer server)
}
}
- ///
- /// Management Client phase: make sure the workspace proxy target list carries exactly one
- /// line for the probe origin — the entry as given, replacing any older line for the same
- /// origin so a previous run's flag state never leaks into this row's verdict. Saving only
- /// when the list actually changed. Runs before the Smart Client so the fetched
- /// configuration carries the new allowlist.
- ///
- private void EnsureAllowlistedTarget(UxConfig config, string entry, System.Text.StringBuilder log)
- {
- ProcessKiller.KillClients("VideoOS.Administration");
- using var mc = ClientSession.Launch(config.AdminClientExe);
- var mcLogin = LoginFlow.WaitForLoginWindow(mc);
- LoginFlow.Login(mc, mcLogin, config);
- LoginFlow.AcceptCertificateTrust(mc);
- var mcMain = LoginFlow.WaitForAdminMain(mc);
- ManagementClientUi.CloseStrayLoginDialogs(mc);
- ManagementClientUi.WaitForTreeReady(mcMain);
-
- // Canonical starting state (fast double-click collapse, no keyboard), then walk the
- // known chain top-down (enumerate rows + click + wheel scroll): each step realizes the
- // rows the next one needs, so no blind walking or long sleeps are involved.
- ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName });
- ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName);
-
- // A previous bad Save once renamed the item (txtName corrupted by an ungated paste -
- // MIP.log 'ItemName.get ... http://httpforever.com'). If the expected child is gone but
- // the workspaces node has a lone child, adopt it and repair its name before touching
- // anything else.
- var workspaceItem = config.McWorkspaceName;
- var paneReady = false;
- try
- {
- ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName);
- }
- catch (InvalidOperationException)
- {
- ManagementClientUi.DumpWorkspaceChildren(mcMain, log);
- // The last toggle strategy may have left the node collapsed: one more expansion
- // pass with no child-name assertion, then look for the lone child.
- try { ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, new string[0]); }
- catch (InvalidOperationException) { }
- ManagementClientUi.DumpWorkspaceChildren(mcMain, log);
- var orphan = ManagementClientUi.OrphanWorkspaceName(mcMain, ManagementClientUi.WorkspacesNodeName);
- if (orphan != null)
- {
- log.AppendLine($"workspace item is named '{orphan}', not '{config.McWorkspaceName}' - adopting and renaming");
- workspaceItem = orphan;
- }
- else
- {
- log.AppendLine("workspaces node has NO children - recreating the item via the shell New menu");
- if (!ManagementClientUi.NewItemUnderNode(mcMain, mc, ManagementClientUi.WorkspacesNodeName, log))
- throw new InvalidOperationException(
- $"Workspace '{config.McWorkspaceName}' is gone from the config and the shell New menu was unavailable.");
- ManagementClientUi.AcceptPendingModal(mc, 5); // a name prompt, if the shell shows one
- paneReady = true; // the New flow leaves the pane showing the fresh item
- log.AppendLine("created new workspace item; filling the pane");
- }
- }
- if (!paneReady) ManagementClientUi.SelectTreeItem(mcMain, workspaceItem);
-
- var nameBox = ManagementClientUi.ExpectControl(mcMain, "txtName", 15);
- var paneName = ReadValue(nameBox);
- if (!string.Equals(paneName, config.McWorkspaceName, StringComparison.Ordinal))
- {
- log.AppendLine($"pane Name field reads '{paneName}'; retyping '{config.McWorkspaceName}'");
- ManagementClientUi.RenameWorkspacePane(mcMain, config.McWorkspaceName);
- }
-
- var targetsBox = ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 30);
- var current = ReadValue(targetsBox);
- log.AppendLine($"proxy targets before: '{current}'");
-
- var lines = (current ?? string.Empty)
- .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
- .Select(line => line.Trim())
- .Where(line => line.Length > 0)
- .ToList();
-
- if (lines.Any(line => string.Equals(line, entry, StringComparison.OrdinalIgnoreCase)))
- {
- log.AppendLine($"entry '{entry}' already allowlisted; no Management Client edit needed");
- RetireManagementClient(mc, mcMain);
- return;
- }
-
- // Same origin with a different flag state is a contradictory entry: exactly one line
- // decides the origin's TLS posture.
- var originToken = entry.Split(' ')[0];
- lines.RemoveAll(line => line.Split(' ')[0].Equals(originToken, StringComparison.OrdinalIgnoreCase));
-
- ManagementClientUi.EnsureCheckBox(mcMain, "chkProxyEnabled", true);
- lines.Add(entry);
- var joined = string.Join("\n", lines);
-
- var pasted = ManagementClientUi.PasteIntoControl(mcMain, "txtProxyTargets", joined);
- var edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 5));
- if (!SameLines(joined, edited))
- {
- log.AppendLine($"paste route {(pasted ? "" : "(clipboard unavailable) ")}did not land (readback '{edited}'); ValuePattern fallback");
- ManagementClientUi.SetValue(mcMain, "txtProxyTargets", joined);
- edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 5));
- }
- log.AppendLine($"proxy targets right after edit: '{edited}'");
-
- if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6))
- {
- log.AppendLine("Save still disabled after edit; flipping chkProxyEnabled twice to dirty the pane");
- ManagementClientUi.DirtyPaneViaCheckbox(mcMain, "chkProxyEnabled");
- if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6))
- {
- log.AppendLine("Save still disabled after checkbox flip; keyboard-nudging the targets box");
- ManagementClientUi.NudgeControlWithKeystroke(mcMain, "txtProxyTargets");
- log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}");
- }
- }
-
- // Guard before the Save: the shell writes EVERY pane field, and one bad run already
- // saved a corrupted Name ('ItemName.get ... http://httpforever.com'). Never re-save
- // a pane whose Name field does not read the workspace name.
- var paneNameNow = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtName", 5));
- if (!string.Equals(paneNameNow, config.McWorkspaceName, StringComparison.Ordinal))
- throw new InvalidOperationException(
- $"Refusing to Save: the Name field reads '{paneNameNow}', expected '{config.McWorkspaceName}'.");
-
- if (!ManagementClientUi.Save(mc, mcMain, 20))
- {
- throw new InvalidOperationException($"Save never became enabled after adding '{entry}' to the proxy targets.");
- }
-
- // Read the pane back: a save the shell silently discarded (or a confirmation whose
- // No/Cancel path was taken) must fail here, not strand the Smart Client phase with a
- // stale allowlist.
- Thread.Sleep(1500);
- var after = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtProxyTargets", 15));
- if (after == null || !after.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
- .Any(line => line.Trim().Equals(entry, StringComparison.OrdinalIgnoreCase)))
- {
- throw new InvalidOperationException($"After Save, the proxy targets read back '{after}' — '{entry}' did not stick.");
- }
-
- log.AppendLine($"allowlisted '{entry}' and saved (verified readback)");
- RetireManagementClient(mc, mcMain);
- }
-
- ///
- /// Closes Management Client after its phase: an MC left open can hold a pending
- /// "Save changes?" modal that steals focus and blocks UIA for the Smart Client phase
- /// (the #56 hang). Close politely, sweep any confirmation it raises, then guarantee exit.
- ///
- private static void RetireManagementClient(ClientSession mc, FlaUI.Core.AutomationElements.Window mcMain)
- {
- try { mcMain.Close(); } catch { }
- ManagementClientUi.AcceptPendingModal(mc, 5);
- ProcessKiller.KillClients("VideoOS.Administration");
- }
-
- private static string ReadValue(AutomationElement element)
- {
- try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; }
- catch { return element.Name; }
- }
-
- /// True when two multiline texts carry the same trimmed, non-empty lines.
- private static bool SameLines(string expected, string actual)
- {
- string[] Norm(string s) => (s ?? string.Empty)
- .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
- .Select(l => l.Trim())
- .Where(l => l.Length > 0)
- .ToArray();
- return Norm(expected).SequenceEqual(Norm(actual));
- }
}
From 93c52119107e4ec77b8b748d103724cc954fea69 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sun, 1 Nov 2026 00:02:44 -0700
Subject: [PATCH 053/187] Record the #56 full-suite green run in the
verification matrix
---
docs/verification-matrix.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md
index 0378c56..4b7d5a6 100644
--- a/docs/verification-matrix.md
+++ b/docs/verification-matrix.md
@@ -191,6 +191,10 @@ device or any http page — https is refused by design, see below).
body chars); the blocked control rendered identically while the same process's direct GET
failed with `actively refused it` — server-origin proof satisfied. Rows 4–6 are pinned by the
unit table (403/405/502 policy table, listener teardown) rather than a live probe run.
+ Regression re-pass on the #56 drop `683FA932…A47A9C58` (run `20261031-235301`): same title
+ and body size — and the fetch probe now provisions its own allowlist entry through the
+ shared Management Client phase (a recreated workspace item proved assuming the entry wrong:
+ the probe went red with 403 until it self-provisioned).
### SC-2 Vendor login flow through the proxy (#55)
@@ -292,6 +296,13 @@ tunnel then fails closed with a logged mint refusal), plus the one-time machine
Artifacts: `%TEMP%\webcontent-sc-proxy-probe\\sc-login-probe-tls-{open,closed}-*.png/.txt.
+ Full-suite green (2026-10-31, drop `683FA932…A47A9C58` hash-verified on both hosts, one
+ detached xunit run, filter `~ProbeProxied`): fetch `20261031-235301`, login
+ `20261031-235408`, tls-open `20261031-235514`, tls-closed `20261031-235635`. The override
+ row rewrote its own allowlist line through the live Management Client edit → guarded Save
+ → verified-readback path, and the suite left the live allowlist at the fail-closed default
+ (no flags).
+
## Known high-risk regressions → pinned by
| Regression (remediation it would undo) | Pinned by |
From 7943bc913ef88e278164c254333541aab04edb27 Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sun, 1 Nov 2026 01:30:09 -0700
Subject: [PATCH 054/187] Tunnel websocket upgrades through the proxy decision
and forward flow (#57)
Decision table gains WebSocketTunnel/WebSocketTunnelRefused dispositions: a plain
ws:// upgrade to an allowlisted origin rides a CONNECT-style byte tunnel to the
Event Server, a wss:// upgrade inside a terminated TLS tunnel gets an explicit 501.
Adds the pure handshake codec (recognize the browser upgrade, rebuild the
origin-form handshake for the target with the Sec-WebSocket-Key byte-identical,
admit only 101), the tunnel message contract (six directional ids, one JSON-string
payload, own limits), and the client-side connection flow: open seam, verbatim 101
relay, two-direction byte pump with handle-close reclaim on either side.
---
.../ProxyFetchContractGuardTests.cs | 2 +
.../ProxyForwardConnectionTests.cs | 328 ++++++++++++++++++
.../ProxyForwardPolicyTests.cs | 115 ++++++
.../ProxyTunnelContractTests.cs | 113 ++++++
.../ProxyWebSocketHandshakeTests.cs | 153 ++++++++
src/WebContent.Tests/WebContent.Tests.csproj | 1 +
src/WebContent/ProxyFetchContract.cs | 137 ++++++++
src/WebContent/ProxyFetchWireCodec.cs | 21 ++
src/WebContent/ProxyForwardConnection.cs | 150 ++++++++
src/WebContent/ProxyForwardPolicy.cs | 38 ++
src/WebContent/ProxyWebSocketHandshake.cs | 226 ++++++++++++
src/WebContent/WebContent.csproj | 1 +
12 files changed, 1285 insertions(+)
create mode 100644 src/WebContent.Tests/ProxyTunnelContractTests.cs
create mode 100644 src/WebContent.Tests/ProxyWebSocketHandshakeTests.cs
create mode 100644 src/WebContent/ProxyWebSocketHandshake.cs
diff --git a/src/WebContent.Tests/ProxyFetchContractGuardTests.cs b/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
index f5aba02..c79adb0 100644
--- a/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
+++ b/src/WebContent.Tests/ProxyFetchContractGuardTests.cs
@@ -29,6 +29,7 @@ public class ProxyFetchContractGuardTests
[InlineData(typeof(ProxyFetchHeader))]
[InlineData(typeof(ProxyLeafRequestPayload))]
[InlineData(typeof(ProxyLeafResponsePayload))]
+ [InlineData(typeof(ProxyTunnelPayload))]
public void EveryPayloadFieldStaysInsideTheMessageWhitelist(Type payloadType)
{
foreach (var field in payloadType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
@@ -44,6 +45,7 @@ public void EveryPayloadFieldStaysInsideTheMessageWhitelist(Type payloadType)
[InlineData(typeof(ProxyFetchHeader))]
[InlineData(typeof(ProxyLeafRequestPayload))]
[InlineData(typeof(ProxyLeafResponsePayload))]
+ [InlineData(typeof(ProxyTunnelPayload))]
public void PayloadsLiveInThePluginNamespaceTheWhitelistGrants(Type payloadType)
{
// The whitelist escape for plug-in types is namespace-based; a payload living under a
diff --git a/src/WebContent.Tests/ProxyForwardConnectionTests.cs b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
index c40843c..c1f6b37 100644
--- a/src/WebContent.Tests/ProxyForwardConnectionTests.cs
+++ b/src/WebContent.Tests/ProxyForwardConnectionTests.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using Xunit;
@@ -334,6 +335,32 @@ public async Task RefusesVerbsInsideTheTunnelAfterTheHandshakeWithoutTouchingThe
Assert.Contains("HTTP/1.1 405", response);
}
+ [Fact]
+ public async Task RefusesInTunnelWebSocketUpgradeWith501InsideTheTunnel()
+ {
+ // #57 posture: a wss:// upgrade inside a terminated tunnel is refused explicitly -
+ // never smuggled through the HTTP fetch path, never taken direct.
+ var fetchCalled = false;
+ Func> fetch = query =>
+ {
+ fetchCalled = true;
+ return Task.FromResult(new ProxyFetchResult { Success = false });
+ };
+
+ var request =
+ "CONNECT cam1.test:8443 HTTP/1.1\r\n\r\n" +
+ "GET /ws/live HTTP/1.1\r\nHost: cam1.test:8443\r\n" +
+ "Connection: Upgrade\r\nUpgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: kEy==\r\n\r\n";
+
+ var response = await HandleTunnel(request, new[] { "https://cam1.test:8443" }, fetch);
+
+ Assert.False(fetchCalled);
+ Assert.StartsWith("HTTP/1.1 200 Connection established\r\n\r\n", response);
+ Assert.Contains("HTTP/1.1 501", response);
+ Assert.Contains("wss", response);
+ }
+
[Fact]
public async Task RefusesConnectWith502WhenNoTlsTerminationIsAvailable()
{
@@ -423,6 +450,289 @@ await ProxyForwardConnection.HandleAsync(
}
}
+ // #57: an allowlisted ws:// upgrade becomes a CONNECT-style byte tunnel to the Event
+ // Server instead of an HTTP forward. The open seam stands in for the MIP-messaging tunnel
+ // (ProxyFetchClient.OpenTunnelAsync in production); the handle stands in for the live
+ // tunnel. Everything after the 101 rides as opaque bytes in both directions.
+
+ [Fact]
+ public async Task TunnelsAllowlistedWebSocketUpgradeAndRelaysTheTargetsHandshakeVerbatim()
+ {
+ ProxyForwardedRequest opened = null;
+ var handle = new FakeTunnelHandle();
+ Func> openTunnel = request =>
+ {
+ opened = request;
+ return Task.FromResult(new ProxyTunnelOpenOutcome
+ {
+ Success = true,
+ Handle = handle,
+ HandshakeBytes = Encoding.UTF8.GetBytes(
+ "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s7AQWlie1Rh0h9M7N8QV3YQ==\r\n\r\n"),
+ });
+ };
+
+ var request =
+ "GET http://10.0.0.5:8080/ws/live HTTP/1.1\r\n" +
+ "Connection: keep-alive, Upgrade\r\nUpgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
+ "\r\n" +
+ "\x81\x05hello"; // browser's first websocket frame rides the same segment
+
+ // Latin-1 keeps the frame bytes byte-faithful into the fake socket (UTF-8 would
+ // expand 0x81 the way a text encoding must not for HTTP framing).
+ var response = await HandleWebSocket(
+ Encoding.GetEncoding("ISO-8859-1").GetBytes(request), openTunnel);
+
+ Assert.Equal("http://10.0.0.5:8080/ws/live", opened.Uri.AbsoluteUri);
+
+ // The target's 101 answer goes to the browser verbatim — no re-rendering, no
+ // re-framing (the browser verifies Sec-WebSocket-Accept against the relayed key).
+ Assert.StartsWith(
+ "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s7AQWlie1Rh0h9M7N8QV3YQ==\r\n\r\n",
+ response);
+
+ // And the frame bytes that followed the upgrade head crossed into the tunnel.
+ Assert.Single(handle.Sent);
+ Assert.Equal(new byte[] { 0x81, 0x05, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o' }, handle.Sent[0]);
+ }
+
+ [Fact]
+ public async Task PumpsTargetBytesBackToTheBrowserUntilTheHandleCloses()
+ {
+ var handle = new FakeTunnelHandle();
+ handle.PushFromTarget(new byte[] { 0x81, 0x02, 0xAA, 0xBB });
+ handle.PushFromTarget(new byte[] { 0x88, 0x00 }); // close frame, then the tunnel ends
+
+ Func> openTunnel = request =>
+ Task.FromResult(new ProxyTunnelOpenOutcome
+ {
+ Success = true,
+ Handle = handle,
+ HandshakeBytes = Encoding.UTF8.GetBytes("HTTP/1.1 101 Switching Protocols\r\n\r\n"),
+ });
+
+ var request =
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\n" +
+ "Connection: Upgrade\r\nUpgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: kEy==\r\n\r\n";
+
+ var response = await HandleWebSocketBytes(Encoding.UTF8.GetBytes(request), openTunnel);
+
+ // The pumped target bytes land raw on the browser socket, in order, after the head -
+ // byte-identical, never base64 or chunk-framed a second time.
+ Assert.StartsWith("HTTP/1.1 101 Switching Protocols\r\n\r\n", Encoding.UTF8.GetString(response));
+ Assert.True(EndsWithBytes(response, new byte[] { 0x81, 0x02, 0xAA, 0xBB, 0x88, 0x00 }),
+ "target bytes did not land byte-identical on the browser socket");
+ }
+
+ [Fact]
+ public async Task RefusesWebSocketTunnelWith502WhenOpenFailsOnTheEventServer()
+ {
+ Func> openTunnel = request =>
+ Task.FromResult(new ProxyTunnelOpenOutcome
+ {
+ Success = false,
+ StatusCode = 401,
+ ErrorCode = ProxyFetchErrorCodes.HandshakeRefused,
+ ErrorMessage = "the target answered 401 instead of 101",
+ });
+
+ var response = await HandleWebSocket(Encoding.UTF8.GetBytes(
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: kEy==\r\n\r\n"),
+ openTunnel);
+
+ Assert.StartsWith("HTTP/1.1 502 Bad Gateway\r\n", response);
+ Assert.Contains("401", response);
+ }
+
+ [Fact]
+ public async Task RefusesWebSocketUpgradeWith502WhenNoTunnelSeamIsWired()
+ {
+ // Defensive wiring: a listener without tunnel plumbing refuses outright - the
+ // upgrade is never smuggled through the HTTP forward path and never taken direct.
+ using (var input = new MemoryStream(Encoding.UTF8.GetBytes(
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: kEy==\r\n\r\n")))
+ using (var output = new MemoryStream())
+ {
+ await ProxyForwardConnection.HandleAsync(input, output, Allowlist, query => Task.FromResult(new ProxyFetchResult { Success = true }), message => { });
+ Assert.StartsWith("HTTP/1.1 502 Bad Gateway", Encoding.UTF8.GetString(output.ToArray()));
+ }
+ }
+
+ [Fact]
+ public async Task ClosesTheTunnelHandleWhenTheBrowserSocketEnds()
+ {
+ var handle = new FakeTunnelHandle();
+ Func> openTunnel = request =>
+ Task.FromResult(new ProxyTunnelOpenOutcome
+ {
+ Success = true,
+ Handle = handle,
+ HandshakeBytes = Encoding.UTF8.GetBytes("HTTP/1.1 101 Switching Protocols\r\n\r\n"),
+ });
+
+ await HandleWebSocket(Encoding.UTF8.GetBytes(
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: kEy==\r\n\r\n"),
+ openTunnel);
+
+ Assert.True(handle.IsClosed);
+ Assert.NotNull(handle.ClosedReason);
+ }
+
+ [Fact]
+ public async Task EndsTheConnectionPromptlyWhenTheTunnelClosesOnTheTargetSide()
+ {
+ // Half-open reclaim: the target closed its end (handle finished) while the browser
+ // kept pushing bytes - the flow must return so the listener can close the socket
+ // instead of parking a task forever.
+ var handle = new FakeTunnelHandle();
+ var browser = new EndlessFrameSocket(Encoding.UTF8.GetBytes(
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: kEy==\r\n\r\n"));
+ Func> openTunnel = request =>
+ Task.FromResult(new ProxyTunnelOpenOutcome
+ {
+ Success = true,
+ Handle = handle,
+ HandshakeBytes = Encoding.UTF8.GetBytes("HTTP/1.1 101 Switching Protocols\r\n\r\n"),
+ });
+
+ using (var output = new MemoryStream())
+ {
+ var flow = ProxyForwardConnection.HandleAsync(
+ browser, output, Allowlist,
+ query => Task.FromResult(new ProxyFetchResult { Success = true }),
+ message => { },
+ null,
+ openTunnel);
+
+ handle.Close("target closed");
+ await Task.WhenAny(flow, Task.Delay(TimeSpan.FromSeconds(10)));
+ Assert.True(flow.IsCompleted, "the flow stayed parked after the target side closed");
+ }
+
+ Assert.True(handle.IsClosed);
+ }
+
+ private static async Task HandleWebSocket(byte[] requestBytes, Func> openTunnel) =>
+ Encoding.UTF8.GetString(await HandleWebSocketBytes(requestBytes, openTunnel));
+
+ private static async Task HandleWebSocketBytes(byte[] requestBytes, Func> openTunnel)
+ {
+ using (var input = new MemoryStream(requestBytes))
+ using (var output = new MemoryStream())
+ {
+ await ProxyForwardConnection.HandleAsync(
+ input, output, Allowlist,
+ query => Task.FromResult(new ProxyFetchResult { Success = true }),
+ message => { },
+ null,
+ openTunnel);
+ return output.ToArray();
+ }
+ }
+
+ ///
+ /// In-memory stand-in for the messaging-backed tunnel handle.
+ ///
+ private sealed class FakeTunnelHandle : ProxyTunnelHandle
+ {
+ internal readonly List Sent = new List();
+ internal string ClosedReason;
+
+ private readonly Queue _fromTarget = new Queue();
+ private TaskCompletionSource _waiter;
+ private bool _closed;
+
+ internal override bool IsClosed => _closed;
+
+ internal void PushFromTarget(byte[] bytes)
+ {
+ lock (_fromTarget)
+ {
+ _fromTarget.Enqueue(bytes);
+ var waiter = _waiter;
+ _waiter = null;
+ waiter?.TrySetResult(bytes);
+ }
+ }
+
+ internal override Task SendAsync(byte[] bytes)
+ {
+ lock (Sent)
+ {
+ Sent.Add(bytes);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ internal override Task NextDataAsync()
+ {
+ lock (_fromTarget)
+ {
+ if (_fromTarget.Count > 0)
+ {
+ return Task.FromResult(_fromTarget.Dequeue());
+ }
+
+ if (_closed)
+ {
+ return Task.FromResult(null);
+ }
+
+ _waiter = new TaskCompletionSource();
+ return _waiter.Task;
+ }
+ }
+
+ internal override void Close(string reason)
+ {
+ lock (_fromTarget)
+ {
+ if (_closed)
+ {
+ return;
+ }
+
+ _closed = true;
+ ClosedReason = reason;
+ var waiter = _waiter;
+ _waiter = null;
+ waiter?.TrySetResult(null);
+ }
+ }
+ }
+
+ ///
+ /// A browser socket that keeps pushing websocket frame bytes forever (the half-open
+ /// case's busy direction); each read completes off-thread so the pump truly yields.
+ ///
+ private sealed class EndlessFrameSocket : MemoryStream
+ {
+ internal EndlessFrameSocket(byte[] buffer) : base(buffer)
+ {
+ }
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
+ Task.Run(() =>
+ {
+ lock (this)
+ {
+ if (Position >= Length)
+ {
+ Position = 0;
+ }
+
+ return base.Read(buffer, offset, Math.Min(count, 64));
+ }
+ });
+ }
+
/// Read side and write side are different streams, like a real SslStream tunnel.
private sealed class DuplexStream : Stream
{
@@ -503,6 +813,24 @@ await ProxyForwardConnection.HandleAsync(
}
}
+ private static bool EndsWithBytes(byte[] actual, byte[] expectedTail)
+ {
+ if (actual.Length < expectedTail.Length)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < expectedTail.Length; i++)
+ {
+ if (actual[actual.Length - expectedTail.Length + i] != expectedTail[i])
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
private static int CountLines(string response, string prefix)
{
var count = 0;
diff --git a/src/WebContent.Tests/ProxyForwardPolicyTests.cs b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
index 2a5ab60..6ba9abb 100644
--- a/src/WebContent.Tests/ProxyForwardPolicyTests.cs
+++ b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
@@ -178,5 +178,120 @@ public void NestedConnectInsideATerminatedTunnelIsRefused()
Assert.Equal(ProxyForwardDisposition.MethodNotAllowed, decision);
Assert.Contains("CONNECT", rejectReason);
}
+
+ // #57: a plain ws:// upgrade reaches an HTTP proxy as an absolute-form GET carrying
+ // Connection/Upgrade headers. The decision table routes it to the CONNECT-style tunnel,
+ // never to the HTTP forward path; the origin gate is the ws origin spelled as http.
+
+ [Fact]
+ public void AllowlistedWebSocketUpgradeTunnelsInsteadOfForwarding()
+ {
+ var head =
+ "GET http://10.0.0.5:8080/ws/live HTTP/1.1\r\n" +
+ "Host: 10.0.0.5:8080\r\n" +
+ "Connection: keep-alive, Upgrade\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Origin: http://10.0.0.5:8080\r\n" +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
+ "\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.WebSocketTunnel, decision);
+ Assert.Equal("http://10.0.0.5:8080/ws/live", request.Uri.AbsoluteUri);
+ Assert.Null(rejectReason);
+ }
+
+ [Fact]
+ public void RefusesWebSocketUpgradeToOriginsNoWorkspaceAllowlists()
+ {
+ var head =
+ "GET http://elsewhere.test/ws HTTP/1.1\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
+ "\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out _, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.OriginNotAllowed, decision);
+ Assert.Contains("elsewhere.test", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesWebSocketUpgradeWithoutTheHandshakeKey()
+ {
+ // A crafted "upgrade" without the mandatory client handshake fields is malformed, not
+ // a tunnel candidate.
+ var head =
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Upgrade: websocket\r\n" +
+ "\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out _, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.BadRequest, decision);
+ Assert.Contains("Sec-WebSocket", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesWebSocketUpgradeWithAnUnsupportedVersion()
+ {
+ var head =
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 8\r\n" +
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
+ "\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out _, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.BadRequest, decision);
+ Assert.Contains("Sec-WebSocket-Version", rejectReason);
+ }
+
+ [Fact]
+ public void RefusesWebSocketUpgradeInsideATerminatedTunnel()
+ {
+ // wss:// reaches this proxy as an upgrade inside a CONNECT tunnel the listener
+ // terminated (#56). Tunnel-replay of that upgrade is out of #57 scope: refuse it
+ // explicitly rather than smuggling it through the HTTP fetch path.
+ var head =
+ "GET /ws/live HTTP/1.1\r\n" +
+ "Host: cam1.test:8443\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
+ "\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(
+ new[] { "https://cam1.test:8443" }, head, new Uri("https://cam1.test:8443"), out _, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.WebSocketTunnelRefused, decision);
+ Assert.Contains("wss", rejectReason);
+ }
+
+ [Fact]
+ public void UpgradesToOtherProtocolsStayOrdinaryForwardedRequests()
+ {
+ // Only the websocket protocol tunnels. A non-websocket upgrade header (h2c chasing)
+ // rides the normal forward path; the fetcher strips hop-by-hop upgrade headers anyway.
+ var head =
+ "GET http://10.0.0.5:8080/ HTTP/1.1\r\n" +
+ "Connection: Upgrade, HTTP2-Settings\r\n" +
+ "Upgrade: h2c\r\n" +
+ "HTTP2-Settings: AABAAABIAAAACw\r\n" +
+ "\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out var request, out _);
+
+ Assert.Equal(ProxyForwardDisposition.Forward, decision);
+ Assert.Equal("http://10.0.0.5:8080/", request.Uri.AbsoluteUri);
+ }
}
}
diff --git a/src/WebContent.Tests/ProxyTunnelContractTests.cs b/src/WebContent.Tests/ProxyTunnelContractTests.cs
new file mode 100644
index 0000000..3b41cf3
--- /dev/null
+++ b/src/WebContent.Tests/ProxyTunnelContractTests.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #57: the tunnel message contract. A websocket tunnel crosses the client↔Event
+ /// Server channel as its OWN directional message pair — never the #53 Response/Chunk
+ /// request/response framing (no double-encoding of already-framed websocket bytes). Payloads
+ /// still travel as single JSON strings (#54 rule).
+ ///
+ public class ProxyTunnelContractTests
+ {
+ [Fact]
+ public void TunnelMessagesFormDirectionalPairsAndNoneEndWithTheIgnoredSuffix()
+ {
+ // The platform's default receiver security ignores ids ending in "Command"; and each
+ // direction gets its own id so a client's frames can never be swallowed by another
+ // client's filter the way a shared id would.
+ var ids = new[]
+ {
+ ProxyMessageIds.TunnelOpenRequestId,
+ ProxyMessageIds.TunnelOpenResponseId,
+ ProxyMessageIds.TunnelDataToTargetId,
+ ProxyMessageIds.TunnelDataFromTargetId,
+ ProxyMessageIds.TunnelCloseToTargetId,
+ ProxyMessageIds.TunnelCloseFromTargetId,
+ };
+
+ foreach (var id in ids)
+ {
+ Assert.StartsWith("WebContent.Proxy.", id, StringComparison.Ordinal);
+ Assert.False(id.EndsWith("Command", StringComparison.Ordinal), id);
+ }
+
+ Assert.Equal(ids.Length, new HashSet(ids).Count);
+ }
+
+ [Fact]
+ public void TunnelOpenRequestRoundTripsUrlAndHandshakeHeadersAsOneString()
+ {
+ var wire = ProxyFetchWireCodec.ToWire(new ProxyTunnelPayload
+ {
+ CorrelationId = "tun-1",
+ Url = "http://10.0.0.5:8080/ws/live",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Upgrade", Value = "websocket" },
+ new ProxyFetchHeader { Name = "Connection", Value = "keep-alive, Upgrade" },
+ new ProxyFetchHeader { Name = "Sec-WebSocket-Key", Value = "dGhlIHNhbXBsZSBub25jZQ==" },
+ new ProxyFetchHeader { Name = "Cookie", Value = "sid=abc" },
+ },
+ });
+
+ Assert.IsType(wire);
+
+ var back = ProxyFetchWireCodec.TunnelFromWire(wire);
+ Assert.NotNull(back);
+ Assert.Equal("tun-1", back.CorrelationId);
+ Assert.Equal("http://10.0.0.5:8080/ws/live", back.Url);
+ Assert.Equal("dGhlIHNhbXBsZSBub25jZQ==", back.Headers[2].Value);
+ Assert.Equal("sid=abc", back.Headers[3].Value);
+ }
+
+ [Fact]
+ public void TunnelDataFrameRoundTripsRawBytesByteIdentical()
+ {
+ // Websocket frame bytes are binary (opcodes, masks, payloads): the tunnel frame must
+ // carry them byte-identical, base64-encoded at the JSON edge only.
+ var raw = new byte[4093];
+ new Random(11).NextBytes(raw);
+ raw[0] = 0x82; // server->client binary frame header
+
+ var back = ProxyFetchWireCodec.TunnelFromWire(ProxyFetchWireCodec.ToWire(new ProxyTunnelPayload
+ {
+ CorrelationId = "tun-2",
+ DataBase64 = Convert.ToBase64String(raw),
+ }));
+
+ Assert.NotNull(back);
+ Assert.Equal(raw, Convert.FromBase64String(back.DataBase64));
+ }
+
+ [Fact]
+ public void TunnelFailureAndCloseFieldsRoundTrip()
+ {
+ var back = ProxyFetchWireCodec.TunnelFromWire(ProxyFetchWireCodec.ToWire(new ProxyTunnelPayload
+ {
+ CorrelationId = "tun-3",
+ Success = false,
+ StatusCode = 401,
+ ErrorCode = ProxyFetchErrorCodes.HandshakeRefused,
+ ErrorMessage = "the target answered 401 instead of 101",
+ }));
+
+ Assert.NotNull(back);
+ Assert.False(back.Success);
+ Assert.Equal(401, back.StatusCode);
+ Assert.Equal(ProxyFetchErrorCodes.HandshakeRefused, back.ErrorCode);
+ Assert.Contains("401", back.ErrorMessage);
+ }
+
+ [Fact]
+ public void ForeignAndGarbageTunnelWireContentDecodeToNull()
+ {
+ Assert.Null(ProxyFetchWireCodec.TunnelFromWire(null));
+ Assert.Null(ProxyFetchWireCodec.TunnelFromWire(42));
+ Assert.Null(ProxyFetchWireCodec.TunnelFromWire("not json at all"));
+ Assert.Null(ProxyFetchWireCodec.TunnelFromWire("{\"Bogus\":1}"));
+ }
+ }
+}
diff --git a/src/WebContent.Tests/ProxyWebSocketHandshakeTests.cs b/src/WebContent.Tests/ProxyWebSocketHandshakeTests.cs
new file mode 100644
index 0000000..b8c59d9
--- /dev/null
+++ b/src/WebContent.Tests/ProxyWebSocketHandshakeTests.cs
@@ -0,0 +1,153 @@
+using System.Collections.Generic;
+using System.Text;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #57: the pure websocket-handshake seam — recognize the browser's ws:// upgrade,
+ /// rebuild the origin-form handshake the Event Server replays at the target (the browser's
+ /// Sec-WebSocket-Key must ride byte-identical so the target's Sec-WebSocket-Accept matches
+ /// what the browser verifies), and decide the target's handshake answer.
+ ///
+ public class ProxyWebSocketHandshakeTests
+ {
+ private static ProxyForwardedRequest Parse(string head)
+ {
+ ProxyForwardedRequest request;
+ string reason;
+ Assert.True(ProxyForwardCodec.TryParseRequestHead(head, out request, out reason), reason);
+ return request;
+ }
+
+ private const string BrowserUpgradeHead =
+ "GET http://10.0.0.5:8080/ws/live?stream=1 HTTP/1.1\r\n" +
+ "Host: 10.0.0.5:8080\r\n" +
+ "Connection: keep-alive, Upgrade\r\n" +
+ "Pragma: no-cache\r\n" +
+ "Cache-Control: no-cache\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Origin: http://10.0.0.5:8080\r\n" +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
+ "Cookie: sid=abc\r\n" +
+ "Proxy-Connection: keep-alive\r\n" +
+ "\r\n";
+
+ [Fact]
+ public void RecognizesTheBrowserWebsocketUpgradeShape()
+ {
+ var request = Parse(BrowserUpgradeHead);
+
+ Assert.True(ProxyWebSocketHandshake.IsWebSocketUpgrade(request));
+ Assert.True(ProxyWebSocketHandshake.HasValidClientHandshake(request, out _));
+ }
+
+ [Fact]
+ public void DoesNotTreatOtherUpgradeProtocolsAsWebsockets()
+ {
+ var request = Parse(
+ "GET http://10.0.0.5:8080/ HTTP/1.1\r\n" +
+ "Connection: Upgrade\r\nUpgrade: h2c\r\n\r\n");
+
+ Assert.False(ProxyWebSocketHandshake.IsWebSocketUpgrade(request));
+ }
+
+ [Fact]
+ public void RebuildsTheOriginFormHandshakeForTheTargetConnection()
+ {
+ var request = Parse(BrowserUpgradeHead);
+ var wire = ProxyWebSocketHandshake.ToTunnelWireHeaders(request);
+
+ var bytes = ProxyWebSocketHandshake.BuildTargetHandshake(request.Uri, wire);
+ var handshake = Encoding.UTF8.GetString(bytes);
+
+ // Origin-form against the target path+query, Host derived from the uri.
+ Assert.StartsWith("GET /ws/live?stream=1 HTTP/1.1\r\n", handshake);
+ Assert.Contains("Host: 10.0.0.5:8080\r\n", handshake);
+
+ // The upgrade pair is restated explicitly; the browser's Sec-WebSocket-Key and
+ // session cookie ride untouched.
+ Assert.Contains("Connection: Upgrade\r\n", handshake);
+ Assert.Contains("Upgrade: websocket\r\n", handshake);
+ Assert.Contains("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n", handshake);
+ Assert.Contains("Cookie: sid=abc\r\n", handshake);
+
+ // Per-hop proxy decoration never travels to the target, and the head terminates.
+ Assert.DoesNotContain("Proxy-Connection", handshake);
+ Assert.EndsWith("\r\n\r\n", handshake);
+ }
+
+ [Fact]
+ public void DropsTheClientsHostHeaderSoTheTargetSeesTheDerivedOne()
+ {
+ var request = Parse(
+ "GET http://10.0.0.5:8080/ws HTTP/1.1\r\n" +
+ "Host: spoofed.internal\r\n" +
+ "Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n");
+ var wire = ProxyWebSocketHandshake.ToTunnelWireHeaders(request);
+
+ var handshake = Encoding.UTF8.GetString(ProxyWebSocketHandshake.BuildTargetHandshake(request.Uri, wire));
+
+ // Exactly one Host line - the derived one - never the client-supplied one.
+ Assert.DoesNotContain("Host: spoofed.internal", handshake);
+ Assert.Equal(2, handshake.Split(new[] { "\r\nHost: " }, System.StringSplitOptions.None).Length);
+ Assert.Contains("\r\nHost: 10.0.0.5:8080\r\n", handshake);
+ }
+
+ [Fact]
+ public void RefusesWireSuppliedHeaderValuesThatWouldInjectExtraHeaders()
+ {
+ var target = new System.Uri("http://10.0.0.5:8080/ws");
+ var wire = new List
+ {
+ new ProxyFetchHeader { Name = "Sec-WebSocket-Key", Value = "ok== key" },
+ new ProxyFetchHeader { Name = "X-Evil", Value = "x\r\nInjected: yes" },
+ };
+
+ var handshake = Encoding.UTF8.GetString(ProxyWebSocketHandshake.BuildTargetHandshake(target, wire));
+
+ Assert.Contains("Sec-WebSocket-Key: ok== key\r\n", handshake);
+ Assert.DoesNotContain("Injected", handshake);
+ }
+
+ [Fact]
+ public void TargetHandshakeEndIsFoundAtTheHeadTerminatorWithBufferedFrameBytesAfterIt()
+ {
+ var bytes = Encoding.UTF8.GetBytes(
+ "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: xyz==\r\n\r\n\x81\x05hello");
+
+ int headLength;
+ Assert.True(ProxyWebSocketHandshake.TryFindHandshakeEnd(bytes, bytes.Length, out headLength));
+ Assert.True(ProxyWebSocketHandshake.IsUpgradeAccepted(bytes, headLength));
+
+ // The head cut leaves the already-buffered frame bytes for the pump, not the head.
+ Assert.True(headLength < bytes.Length);
+ Assert.Equal('\r', (char)bytes[headLength - 2]);
+ Assert.Equal('\n', (char)bytes[headLength - 1]);
+ }
+
+ [Fact]
+ public void HandshakeHeadThatNeverTerminatesIsIncomplete()
+ {
+ var bytes = Encoding.UTF8.GetBytes("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n");
+
+ int headLength;
+ Assert.False(ProxyWebSocketHandshake.TryFindHandshakeEnd(bytes, bytes.Length, out headLength));
+ }
+
+ [Theory]
+ [InlineData("HTTP/1.1 101 Switching Protocols\r\n\r\n", true)]
+ [InlineData("HTTP/1.0 101 Switching Protocols\r\n\r\n", true)]
+ [InlineData("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", false)]
+ [InlineData("HTTP/1.1 401 Unauthorized\r\n\r\n", false)]
+ [InlineData("HTPX/1.1 101 Switching Protocols\r\n\r\n", false)]
+ public void OnlyTheUpgradeStatusAdmitsTheTunnel(string head, bool expected)
+ {
+ var bytes = Encoding.UTF8.GetBytes(head);
+ int headLength;
+ Assert.True(ProxyWebSocketHandshake.TryFindHandshakeEnd(bytes, bytes.Length, out headLength));
+ Assert.Equal(expected, ProxyWebSocketHandshake.IsUpgradeAccepted(bytes, headLength));
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 95f1e14..96c863c 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -43,6 +43,7 @@
+
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index a2d4fc2..e2b34a4 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Threading.Tasks;
namespace WebContent
{
@@ -24,6 +25,20 @@ internal static class ProxyMessageIds
/// allowlisted origin, carrying the client-generated SubjectPublicKeyInfo.
internal const string LeafRequestId = "WebContent.Proxy.LeafRequest";
internal const string LeafResponseId = "WebContent.Proxy.LeafResponse";
+
+ ///
+ /// Ticket #57: the websocket tunnel's directional message pairs. Open is a request/
+ /// response handshake exchange (the Event Server reports whether the target accepted the
+ /// upgrade); data and close each get one id PER DIRECTION, so tunnel bytes flow as their
+ /// own messages — never through the #53 Response/Chunk fetch framing, and one client's
+ /// frames can never be swallowed by another client's filter.
+ ///
+ internal const string TunnelOpenRequestId = "WebContent.Proxy.TunnelOpenRequest";
+ internal const string TunnelOpenResponseId = "WebContent.Proxy.TunnelOpenResponse";
+ internal const string TunnelDataToTargetId = "WebContent.Proxy.TunnelDataToTarget";
+ internal const string TunnelDataFromTargetId = "WebContent.Proxy.TunnelDataFromTarget";
+ internal const string TunnelCloseToTargetId = "WebContent.Proxy.TunnelCloseToTarget";
+ internal const string TunnelCloseFromTargetId = "WebContent.Proxy.TunnelCloseFromTarget";
}
///
@@ -79,6 +94,12 @@ internal static class ProxyFetchErrorCodes
/// so no loopback leaf certificate can be minted.
///
internal const string NoAuthority = "NoAuthority";
+
+ ///
+ /// Ticket #57: the tunnel's target answered the websocket handshake with something other
+ /// than 101 (a plain 200, a vendor 401) — the upgrade is refused, no tunnel exists.
+ ///
+ internal const string HandshakeRefused = "HandshakeRefused";
}
///
@@ -283,4 +304,120 @@ internal static ProxyFetchLimits Default()
return new ProxyFetchLimits();
}
}
+
+ ///
+ /// Ticket #57: the single payload shape for every websocket-tunnel message (open request,
+ /// open response, data frame in either direction, close in either direction). Which fields
+ /// matter is decided by the message id the payload rides on, so one JSON string covers the
+ /// whole protocol like every other proxy payload (#54). The namespace/whitelist guards in
+ /// ProxyFetchContractGuardTests pin the field types.
+ ///
+ [Serializable]
+ public class ProxyTunnelPayload
+ {
+ /// The tunnel id; one Guid for the whole life of one tunnel.
+ public string CorrelationId;
+
+ /// Open request: the ws:// target url (http-scheme origin + path).
+ public string Url;
+
+ ///
+ /// Open request: the browser's handshake headers minus per-hop/proxy decoration, casing
+ /// and values intact — the target computes Sec-WebSocket-Accept from the relayed
+ /// Sec-WebSocket-Key, so the key must not be touched.
+ ///
+ public List Headers;
+
+ ///
+ /// Data frame: raw tunnel bytes base64-encoded at the JSON edge only. Open response:
+ /// the target's raw handshake answer (101 head plus any already-buffered frame bytes),
+ /// relayed verbatim so the browser sees exactly the target's Sec-WebSocket-Accept.
+ ///
+ public string DataBase64;
+
+ /// Open response: whether the tunnel is up. Close frames leave it default.
+ public bool Success;
+
+ /// Open response: the target's handshake status line code (101, or whatever else).
+ public int StatusCode;
+
+ // Failure/close fields.
+ public string ErrorCode;
+ public string ErrorMessage;
+ }
+
+ ///
+ /// Ticket #57: bounds the websocket tunnel enforces on both sides — the Event Server's
+ /// connect/handshake budget for the target leg, the idle timeout that reclaims stalled or
+ /// half-open tunnels, the per-message raw byte cap, and the concurrency caps that keep the
+ /// Event Server from accumulating sockets.
+ ///
+ internal sealed class ProxyTunnelLimits
+ {
+ internal const int DefaultConnectTimeoutMs = 5000;
+ internal const int DefaultHandshakeTimeoutMs = 10000;
+ internal const int DefaultIdleTimeoutMs = 5 * 60 * 1000;
+ internal const int DefaultHandshakeMaxBytes = 64 * 1024;
+ internal const int DefaultFrameMaxBytes = 16 * 1024;
+ internal const int DefaultEsMaxTunnels = 8;
+ internal const int DefaultClientMaxTunnels = 16;
+ internal const int DefaultClientOpenDeadlineMs = 30000;
+
+ internal int ConnectTimeoutMs = DefaultConnectTimeoutMs;
+ internal int HandshakeTimeoutMs = DefaultHandshakeTimeoutMs;
+ internal int IdleTimeoutMs = DefaultIdleTimeoutMs;
+ internal int HandshakeMaxBytes = DefaultHandshakeMaxBytes;
+ internal int FrameMaxBytes = DefaultFrameMaxBytes;
+ internal int EsMaxTunnels = DefaultEsMaxTunnels;
+ internal int ClientMaxTunnels = DefaultClientMaxTunnels;
+ internal int ClientOpenDeadlineMs = DefaultClientOpenDeadlineMs;
+
+ internal static ProxyTunnelLimits Default()
+ {
+ return new ProxyTunnelLimits();
+ }
+ }
+
+ ///
+ /// Ticket #57: the client-side seam a tunnelled browser connection pumps against. The
+ /// production implementation rides MIP messaging (ProxyFetchClient); tests substitute an
+ /// in-memory tunnel. Byte-oriented, not frame-oriented: everything after the 101 is opaque
+ /// RFC 6455 framing to the proxy, exactly like a CONNECT tunnel carries opaque TLS.
+ ///
+ internal abstract class ProxyTunnelHandle
+ {
+ internal string TunnelId;
+
+ /// True once either side closed the tunnel (no further data will arrive).
+ internal abstract bool IsClosed { get; }
+
+ /// Relay browser bytes toward the target; no-op once closed.
+ internal abstract Task SendAsync(byte[] bytes);
+
+ ///
+ /// Await the next target→browser bytes; null once the tunnel is closed (either side).
+ ///
+ internal abstract Task NextDataAsync();
+
+ /// Close the tunnel and tell the Event Server; idempotent.
+ internal abstract void Close(string reason);
+ }
+
+ ///
+ /// Ticket #57: outcome of a tunnel-open attempt on the client side. Success carries the
+ /// handle to pump plus the target's raw handshake answer to write to the browser socket
+ /// before the first pump byte; failure carries the refusal to render as 502.
+ ///
+ internal sealed class ProxyTunnelOpenOutcome
+ {
+ internal bool Success;
+ internal ProxyTunnelHandle Handle;
+
+ /// The target's 101 answer (head + buffered bytes), relayed verbatim.
+ internal byte[] HandshakeBytes;
+
+ internal int StatusCode;
+ internal string ErrorCode;
+ internal string ErrorMessage;
+ }
}
diff --git a/src/WebContent/ProxyFetchWireCodec.cs b/src/WebContent/ProxyFetchWireCodec.cs
index cc553cb..ac40720 100644
--- a/src/WebContent/ProxyFetchWireCodec.cs
+++ b/src/WebContent/ProxyFetchWireCodec.cs
@@ -34,6 +34,9 @@ internal static class ProxyFetchWireCodec
private static readonly DataContractJsonSerializer LeafResponseSerializer =
new DataContractJsonSerializer(typeof(ProxyLeafResponsePayload));
+ private static readonly DataContractJsonSerializer TunnelSerializer =
+ new DataContractJsonSerializer(typeof(ProxyTunnelPayload));
+
/// Encode a request payload for Message.Data.
internal static string ToWire(ProxyFetchRequestPayload payload)
{
@@ -107,6 +110,24 @@ internal static ProxyLeafResponsePayload LeafResponseFromWire(object data)
return Deserialize(json, LeafResponseSerializer);
}
+ /// Encode one websocket-tunnel message (#57) for Message.Data.
+ internal static string ToWire(ProxyTunnelPayload payload)
+ {
+ return Serialize(payload, TunnelSerializer);
+ }
+
+ /// Decode one websocket-tunnel message from Message.Data; null when unusable.
+ internal static ProxyTunnelPayload TunnelFromWire(object data)
+ {
+ var json = data as string;
+ if (json == null)
+ {
+ return null;
+ }
+
+ return Deserialize(json, TunnelSerializer);
+ }
+
private static string Serialize(object payload, DataContractJsonSerializer serializer)
{
using (var stream = new MemoryStream())
diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs
index c2f506c..e5c5ea8 100644
--- a/src/WebContent/ProxyForwardConnection.cs
+++ b/src/WebContent/ProxyForwardConnection.cs
@@ -52,6 +52,25 @@ internal static async Task HandleAsync(
Func> fetch,
Action log,
Func> unwrapTunnelTls)
+ {
+ await HandleAsync(input, output, canonicalTargets, fetch, log, unwrapTunnelTls, null);
+ }
+
+ ///
+ /// Optional websocket-tunnel seam (#57): given the decided upgrade request, open the
+ /// CONNECT-style byte tunnel to the target through the Event Server and hand back a
+ /// handle to pump plus the target's verbatim 101 answer. Production passes
+ /// ProxyFetchClient.OpenTunnelAsync ; the pure flow stays testable with an
+ /// in-memory handle.
+ ///
+ internal static async Task HandleAsync(
+ Stream input,
+ Stream output,
+ System.Collections.Generic.IEnumerable canonicalTargets,
+ Func> fetch,
+ Action log,
+ Func> unwrapTunnelTls,
+ Func> openTunnel)
{
var head = await ReadRequestHeadAsync(input);
if (head == null)
@@ -127,6 +146,48 @@ internal static async Task HandleAsync(
log(rejectReason);
await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", rejectReason + " The workspace proxy supports http(s) allowlisted targets fetched by the VMS server; https pages need a CONNECT tunnel this proxy does not provide."));
return;
+
+ case ProxyForwardDisposition.WebSocketTunnelRefused:
+ // A websocket upgrade this proxy cannot tunnel (wss:// inside a terminated
+ // TLS tunnel): an explicit status, never smuggled into the fetch path.
+ log(rejectReason);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(501, "Not Implemented", rejectReason));
+ return;
+
+ case ProxyForwardDisposition.WebSocketTunnel:
+ if (openTunnel == null)
+ {
+ var tunnelWiring = $"The websocket upgrade to '{request.RawTarget}' cannot be tunnelled by this listener; ws:// targets ride CONNECT-style tunnels the VMS server opens.";
+ log(tunnelWiring);
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", tunnelWiring));
+ return;
+ }
+
+ ProxyTunnelOpenOutcome outcome;
+ try
+ {
+ outcome = await openTunnel(request);
+ }
+ catch (Exception exception)
+ {
+ log($"Proxy websocket tunnel open for '{request.RawTarget}' threw: {exception.Message}");
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", $"The VMS proxy tunnel failed to open: {exception.Message}"));
+ return;
+ }
+
+ if (outcome == null || !outcome.Success)
+ {
+ var openFailure = outcome == null
+ ? "The Event Server tunnel opener returned no outcome."
+ : outcome.ErrorMessage;
+ log($"Proxy websocket tunnel to '{request.RawTarget}' refused (code={outcome?.ErrorCode}): {openFailure}");
+ await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse(502, "Bad Gateway", openFailure));
+ return;
+ }
+
+ log($"Proxy websocket tunnel to '{request.RawTarget}' established; pumping bytes in both directions.");
+ await ServeWebSocketTunnelAsync(head.Leftover, input, output, outcome, log);
+ return;
}
await ServeForwardedRequestAsync(request, head.Leftover, input, output, fetch, log);
@@ -174,6 +235,14 @@ private static async Task ServeTunnelRequestsAsync(
pending = head.Leftover;
continue;
+ case ProxyForwardDisposition.WebSocketTunnelRefused:
+ // #57 posture: a wss:// upgrade inside the terminated tunnel gets an
+ // explicit refusal on the tunnel; the tunnel itself stays usable.
+ log(rejectReason);
+ await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse(501, "Not Implemented", rejectReason));
+ pending = head.Leftover;
+ continue;
+
default:
// OriginNotAllowed inside a tunnel means the inner target drifted off the
// accepted origin (codec already blocks that) - refuse and keep the tunnel.
@@ -193,6 +262,87 @@ private static async Task ServeTunnelRequestsAsync(
}
}
+ ///
+ /// The #57 tunnel flow after a successful open: the target's raw 101 answer goes to the
+ /// browser socket verbatim (the browser validates Sec-WebSocket-Accept against the key
+ /// the tunnel relayed untouched), then the connection becomes a pure byte pump in both
+ /// directions — RFC 6455 framing is opaque here, exactly like CONNECT carrying opaque TLS.
+ /// The traffic never enters the HTTP forward/chunking path, so nothing is re-encoded.
+ ///
+ private static async Task ServeWebSocketTunnelAsync(
+ byte[] leftover,
+ Stream input,
+ Stream output,
+ ProxyTunnelOpenOutcome outcome,
+ Action log)
+ {
+ await WriteAsync(output, outcome.HandshakeBytes ?? new byte[0]);
+
+ var browserToTarget = PumpBrowserToTunnelAsync(leftover, input, outcome.Handle, log);
+ var targetToBrowser = PumpTunnelToBrowserAsync(outcome.Handle, output, log);
+
+ // Whichever side ends first ends the connection: browser EOF or socket fault, or the
+ // target/messaging side closing. The other pump unwinds on its own - the socket
+ // disposal in the caller's using-block breaks its pending read, or the closed handle
+ // answers the next wait - and its task is intentionally not awaited here so a silent
+ // half-open browser socket cannot park this flow forever.
+ await Task.WhenAny(browserToTarget, targetToBrowser);
+ outcome.Handle.Close("the proxied websocket connection ended");
+ }
+
+ private static async Task PumpBrowserToTunnelAsync(byte[] leftover, Stream input, ProxyTunnelHandle handle, Action log)
+ {
+ try
+ {
+ if (leftover != null && leftover.Length > 0)
+ {
+ // The upgrade head's pipelined bytes are the browser's first websocket frame(s).
+ await handle.SendAsync(leftover);
+ }
+
+ var buffer = new byte[ProxyTunnelLimits.DefaultFrameMaxBytes];
+ while (!handle.IsClosed)
+ {
+ var read = await input.ReadAsync(buffer, 0, buffer.Length);
+ if (read <= 0)
+ {
+ return;
+ }
+
+ var bytes = new byte[read];
+ Buffer.BlockCopy(buffer, 0, bytes, 0, read);
+ await handle.SendAsync(bytes);
+ }
+ }
+ catch (Exception exception)
+ {
+ // Socket disposal at teardown is the normal ending of the losing direction.
+ log($"Proxy websocket tunnel browser-to-target pump ended: {exception.Message}");
+ }
+ }
+
+ private static async Task PumpTunnelToBrowserAsync(ProxyTunnelHandle handle, Stream output, Action log)
+ {
+ try
+ {
+ while (true)
+ {
+ var bytes = await handle.NextDataAsync();
+ if (bytes == null)
+ {
+ return;
+ }
+
+ await output.WriteAsync(bytes, 0, bytes.Length);
+ await output.FlushAsync();
+ }
+ }
+ catch (Exception exception)
+ {
+ log($"Proxy websocket tunnel target-to-browser pump ended: {exception.Message}");
+ }
+ }
+
///
/// Body-resolve, fetch, and render one decided-Forward request. Returns the pipelined
/// remainder bytes past this request's body for the caller's next request (#56 tunnels),
diff --git a/src/WebContent/ProxyForwardPolicy.cs b/src/WebContent/ProxyForwardPolicy.cs
index 32a0828..ee4d3ac 100644
--- a/src/WebContent/ProxyForwardPolicy.cs
+++ b/src/WebContent/ProxyForwardPolicy.cs
@@ -26,6 +26,18 @@ internal enum ProxyForwardDisposition
/// CONNECT that cannot be terminated here — 502, never taken direct.
ConnectRefused,
+
+ ///
+ /// Plain ws:// upgrade to an allowlisted origin (#57): carried as a CONNECT-style byte
+ /// tunnel to the Event Server, never through the HTTP request/response forward path.
+ ///
+ WebSocketTunnel,
+
+ ///
+ /// A websocket upgrade this proxy cannot tunnel (wss:// inside a terminated TLS tunnel —
+ /// out of #57 scope) — refused with an explicit status, never smuggled through a forward.
+ ///
+ WebSocketTunnelRefused,
}
///
@@ -109,6 +121,32 @@ internal static ProxyForwardDisposition Decide(
return ProxyForwardDisposition.MethodNotAllowed;
}
+ // #57: a websocket upgrade is not an HTTP forward. A plain ws:// upgrade (http-scheme
+ // origin) to an allowlisted target tunnels as raw bytes; a wss:// upgrade inside a
+ // TLS tunnel the listener terminated is refused outright — never relayed through the
+ // chunked fetch path, never taken direct.
+ if (ProxyWebSocketHandshake.IsWebSocketUpgrade(request))
+ {
+ if (tunnelOrigin != null || request.Uri.Scheme != Uri.UriSchemeHttp)
+ {
+ rejectReason = "A wss:// websocket upgrade cannot be tunnelled through this proxy: https targets terminate as TLS at the loopback listener, and only plain ws:// upgrades ride the byte tunnel (wss tunneling is not implemented).";
+ return ProxyForwardDisposition.WebSocketTunnelRefused;
+ }
+
+ if (!ProxyWebSocketHandshake.HasValidClientHandshake(request, out rejectReason))
+ {
+ return ProxyForwardDisposition.BadRequest;
+ }
+
+ if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, request.Uri))
+ {
+ rejectReason = $"The websocket origin '{ProxyPolicy.CanonicalOrigin(request.Uri)}' is not in the workspace proxy allowlist; the tunnel is refused and the browser is never taken direct.";
+ return ProxyForwardDisposition.OriginNotAllowed;
+ }
+
+ return ProxyForwardDisposition.WebSocketTunnel;
+ }
+
if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, request.Uri))
{
rejectReason = $"The request origin '{ProxyPolicy.CanonicalOrigin(request.Uri)}' is not in the workspace proxy allowlist.";
diff --git a/src/WebContent/ProxyWebSocketHandshake.cs b/src/WebContent/ProxyWebSocketHandshake.cs
new file mode 100644
index 0000000..1909743
--- /dev/null
+++ b/src/WebContent/ProxyWebSocketHandshake.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace WebContent
+{
+ ///
+ /// Pure websocket-handshake seam for the #57 CONNECT-style tunnel. A plain ws:// request
+ /// reaches the loopback forwarding proxy as an absolute-form GET carrying the RFC 6455
+ /// upgrade headers; this class recognizes that request shape, validates the mandatory
+ /// client-handshake fields, rebuilds the origin-form handshake the Event Server replays at
+ /// the target, and validates the target's 101 response. Nothing here touches a socket — the
+ /// byte plumbing lives in (client side) and
+ /// (Event Server side).
+ ///
+ internal static class ProxyWebSocketHandshake
+ {
+ /// The only RFC 6455 client version browsers send and the only one relayed.
+ internal const string SupportedVersion = "13";
+
+ ///
+ /// True when the parsed request is a websocket protocol upgrade: a GET whose Connection
+ /// header lists the upgrade token and whose Upgrade header names websocket. Other upgrade
+ /// protocols (h2c and friends) are not websockets and stay on the ordinary forward path.
+ ///
+ internal static bool IsWebSocketUpgrade(ProxyForwardedRequest request)
+ {
+ if (request == null || request.Method != "GET")
+ {
+ return false;
+ }
+
+ string connection;
+ request.Headers.TryGetValue("Connection", out connection);
+ if (!ConnectionCarriesUpgrade(connection))
+ {
+ return false;
+ }
+
+ string upgrade;
+ request.Headers.TryGetValue("Upgrade", out upgrade);
+ return string.Equals((upgrade ?? string.Empty).Trim(), "websocket", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// A tunnel-replayable upgrade needs the mandatory RFC 6455 client fields: a non-blank
+ /// Sec-WebSocket-Key and Sec-WebSocket-Version 13. Anything else is a malformed request,
+ /// refused with 400 rather than half-tunnelled.
+ ///
+ internal static bool HasValidClientHandshake(ProxyForwardedRequest request, out string rejectReason)
+ {
+ rejectReason = null;
+
+ string key;
+ request.Headers.TryGetValue("Sec-WebSocket-Key", out key);
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ rejectReason = "The websocket upgrade carries no Sec-WebSocket-Key; a relayable upgrade needs the full RFC 6455 client handshake.";
+ return false;
+ }
+
+ string version;
+ request.Headers.TryGetValue("Sec-WebSocket-Version", out version);
+ if (!string.Equals((version ?? string.Empty).Trim(), SupportedVersion, StringComparison.Ordinal))
+ {
+ rejectReason = $"The websocket upgrade declares Sec-WebSocket-Version '{version}'; only version {SupportedVersion} is relayed.";
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Request headers the tunnel handshake must NOT carry to the target: per-hop proxy
+ /// headers and Host (restated from the target uri). Unlike the HTTP forward path, Upgrade
+ /// and Connection ride along — the whole point of the tunnel is that the target answers
+ /// the upgrade — and Sec-WebSocket-Key passes through byte-identical so the target's
+ /// Sec-WebSocket-Accept matches what the browser will verify.
+ ///
+ private static readonly HashSet HandshakeBlockedHeaders =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Host",
+ "Proxy-Connection",
+ "Proxy-Authenticate",
+ "Proxy-Authorization",
+ "Content-Length",
+ "Transfer-Encoding",
+ "TE",
+ "Trailer",
+ "Keep-Alive",
+ };
+
+ ///
+ /// Rebuild the origin-form handshake request the Event Server writes onto its raw socket
+ /// to the target: request line against the target's path+query, a Host derived from the
+ /// uri (never from the client), the relayed headers, and an explicit "Connection: Upgrade"
+ /// / "Upgrade: websocket" pair regardless of how the browser spelled its Connection list.
+ ///
+ internal static byte[] BuildTargetHandshake(Uri target, List relayHeaders)
+ {
+ var pathAndQuery = string.IsNullOrEmpty(target.PathAndQuery) ? "/" : target.PathAndQuery;
+ var head = new StringBuilder();
+ head.Append($"GET {pathAndQuery} HTTP/1.1\r\n");
+ head.Append($"Host: {GetHostHeader(target)}\r\n");
+
+ if (relayHeaders != null)
+ {
+ foreach (var header in relayHeaders)
+ {
+ if (header == null || string.IsNullOrEmpty(header.Name) || header.Value == null)
+ {
+ continue;
+ }
+
+ // A wire-supplied value with line breaks could inject headers at the target;
+ // the gate lives here, same as the fetcher's own header replay.
+ if (header.Value.IndexOf('\r') >= 0 || header.Value.IndexOf('\n') >= 0 ||
+ header.Name.IndexOf(':') >= 0 || HandshakeBlockedHeaders.Contains(header.Name))
+ {
+ continue;
+ }
+
+ head.Append($"{header.Name}: {header.Value}\r\n");
+ }
+ }
+
+ head.Append("Connection: Upgrade\r\n");
+ head.Append("Upgrade: websocket\r\n");
+ head.Append("\r\n");
+ return Encoding.UTF8.GetBytes(head.ToString());
+ }
+
+ ///
+ /// The headers to carry on the tunnel-open wire message: the browser's parsed headers
+ /// minus the hop-by-hop/proxy set. Casing and values are untouched.
+ ///
+ internal static List ToTunnelWireHeaders(ProxyForwardedRequest request)
+ {
+ var outbound = new List();
+ foreach (var header in request.Headers)
+ {
+ if (HandshakeBlockedHeaders.Contains(header.Key))
+ {
+ continue;
+ }
+
+ outbound.Add(new ProxyFetchHeader { Name = header.Key, Value = header.Value });
+ }
+
+ return outbound;
+ }
+
+ ///
+ /// The verdict on the target's handshake response bytes: complete (with status), still
+ /// incomplete, or overrun. The head terminator is CRLFCRLF (bare-LF tolerated), matching
+ /// the request-head reader's rules.
+ ///
+ internal static bool TryFindHandshakeEnd(byte[] buffer, int received, out int headLength)
+ {
+ headLength = -1;
+ for (var i = 0; i + 1 < received; i++)
+ {
+ if (buffer[i] == '\n' && buffer[i + 1] == '\n')
+ {
+ headLength = i + 2;
+ return true;
+ }
+
+ if (i + 3 < received &&
+ buffer[i] == '\r' && buffer[i + 1] == '\n' && buffer[i + 2] == '\r' && buffer[i + 3] == '\n')
+ {
+ headLength = i + 4;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// True when the handshake response status line is HTTP/1.1 (or HTTP/1.0) 101 — the only
+ /// answer that admits the tunnel. Any other status (a plain 200 on the ws path, a vendor's
+ /// 401) means the target refused the upgrade.
+ ///
+ internal static bool IsUpgradeAccepted(byte[] headBytes, int headLength)
+ {
+ var head = Encoding.UTF8.GetString(headBytes, 0, Math.Min(headLength, headBytes.Length));
+ var firstLine = head.Split('\n')[0].Trim();
+ var parts = firstLine.Split(' ');
+ return parts.Length >= 2 &&
+ parts[0].StartsWith("HTTP/1", StringComparison.Ordinal) &&
+ parts[1] == "101";
+ }
+
+ private static bool ConnectionCarriesUpgrade(string connection)
+ {
+ if (string.IsNullOrEmpty(connection))
+ {
+ return false;
+ }
+
+ // Connection is a comma list ("keep-alive, Upgrade" is what Chromium actually sends
+ // for a websocket request through a proxy).
+ foreach (var token in connection.Split(','))
+ {
+ if (string.Equals(token.Trim(), "upgrade", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string GetHostHeader(Uri target)
+ {
+ // Host header rules: omit the port only for the scheme default. Uri.IsDefaultPort
+ // already speaks http/https, which is all the tunnel relays.
+ return target.IsDefaultPort ? target.IdnHost : $"{target.IdnHost}:{target.Port}";
+ }
+ }
+}
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index 40b17a3..631e4fd 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -120,6 +120,7 @@
+
From 0e304e1878e6f4f48ac95b35cc5d7a05eee190ef Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sun, 1 Nov 2026 01:45:41 -0700
Subject: [PATCH 055/187] Add the Event Server websocket tunnel server (#57)
Replays the rebuilt origin-form handshake over a raw socket, admits only on 101,
then pumps raw bytes both directions: serialized client->target writes, target
reads streamed as TunnelData frames, close propagated both ways. Event Server
protection: concurrency cap, per-frame byte cap, idle/half-open reclamation that
drops the socket and unwinds the pumps.
---
.../ProxyTunnelServerTests.cs | 426 ++++++++++++++
src/WebContent.Tests/WebContent.Tests.csproj | 1 +
src/WebContent/ProxyFetchContract.cs | 14 +
src/WebContent/ProxyTunnelServer.cs | 523 ++++++++++++++++++
src/WebContent/WebContent.csproj | 1 +
5 files changed, 965 insertions(+)
create mode 100644 src/WebContent.Tests/ProxyTunnelServerTests.cs
create mode 100644 src/WebContent/ProxyTunnelServer.cs
diff --git a/src/WebContent.Tests/ProxyTunnelServerTests.cs b/src/WebContent.Tests/ProxyTunnelServerTests.cs
new file mode 100644
index 0000000..5468f0b
--- /dev/null
+++ b/src/WebContent.Tests/ProxyTunnelServerTests.cs
@@ -0,0 +1,426 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #57: the Event Server half of the websocket tunnel. It replays the rebuilt
+ /// handshake over a raw socket to the target, admits the tunnel only on 101, then pumps raw
+ /// bytes in both directions until either side closes — never through the HTTP chunking path.
+ /// Idle tunnels are reclaimed and a concurrency cap protects the Event Server. The in-process
+ /// TCP target stands in for the vendor device; the messaging layer is host plumbing covered
+ /// by the live rows.
+ ///
+ public class ProxyTunnelServerTests : IDisposable
+ {
+ private WsTarget _target;
+
+ public void Dispose() => _target?.Dispose();
+
+ private static ProxyTunnelLimits TestLimits()
+ {
+ // Test-sized bounds: fast connects, fast handshakes, fast idle reclaim.
+ return new ProxyTunnelLimits
+ {
+ ConnectTimeoutMs = 2000,
+ HandshakeTimeoutMs = 1000,
+ IdleTimeoutMs = 150,
+ };
+ }
+
+ [Fact]
+ public async Task OpensTheTunnelOnlyWhenTheTargetAcceptsTheUpgrade()
+ {
+ var server = NewServer();
+ var opened = await server.OpenAsync(OpenPayload("/ws/live"), Capture);
+
+ Assert.True(opened.Success, opened.ErrorMessage);
+ Assert.Equal(101, opened.StatusCode);
+ Assert.Contains("HTTP/1.1 101", Encoding.UTF8.GetString(opened.HandshakeBytes));
+ Assert.Equal(1, server.ActiveTunnels);
+
+ // The target saw the rebuilt origin-form handshake with the browser's key intact.
+ await WaitForJournal(_target, "GET /ws/live HTTP/1.1");
+ Assert.Contains("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", _target.LastRequestHead);
+ Assert.Contains("Host: 127.0.0.1:" + _target.Port, _target.LastRequestHead);
+ }
+
+ [Fact]
+ public async Task PumpsBytesInBothDirectionsUntilEitherSideCloses()
+ {
+ var server = NewServer();
+ var opened = await server.OpenAsync(OpenPayload("/ws/echo"), Capture);
+ Assert.True(opened.Success, opened.ErrorMessage);
+
+ // Client -> target -> echo -> client, byte-identical: raw websocket frame bytes,
+ // base64 only at the JSON edge, no Response/Chunk double-framing.
+ var frame = new byte[] { 0x81, 0x05, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o' };
+ server.OnClientData(new ProxyTunnelPayload
+ {
+ CorrelationId = _tunnelId,
+ DataBase64 = Convert.ToBase64String(frame),
+ });
+
+ var echoed = await WaitForDataFrame();
+ Assert.Equal(frame, Convert.FromBase64String(echoed.DataBase64));
+ Assert.Equal(_tunnelId, echoed.CorrelationId);
+ }
+
+ [Fact]
+ public async Task RefusesTheTunnelWhenTheTargetAnswersSomethingOtherThan101()
+ {
+ _target = new WsTarget(upgrade: false);
+ var server = NewServer();
+
+ var opened = await server.OpenAsync(OpenPayload("/ws/live"), Capture);
+
+ Assert.False(opened.Success);
+ Assert.Equal(ProxyFetchErrorCodes.HandshakeRefused, opened.ErrorCode);
+ Assert.Equal(401, opened.StatusCode);
+ Assert.Equal(0, server.ActiveTunnels);
+ }
+
+ [Fact]
+ public async Task RefusesTheTunnelWhenTheTargetConnectionFails()
+ {
+ // A port with no listener at all: connect failure, mapped like the fetcher's.
+ var dead = FreePort();
+ var server = NewServer();
+
+ var opened = await server.OpenAsync(new ProxyTunnelPayload
+ {
+ CorrelationId = "dead-tunnel",
+ Url = $"http://127.0.0.1:{dead}/ws",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Upgrade", Value = "websocket" },
+ new ProxyFetchHeader { Name = "Connection", Value = "Upgrade" },
+ new ProxyFetchHeader { Name = "Sec-WebSocket-Key", Value = "k==" },
+ },
+ }, Capture);
+
+ Assert.False(opened.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Unreachable, opened.ErrorCode);
+ }
+
+ [Fact]
+ public void RefusesNonHttpTargetsOnTheServerEvenAfterTheClientGate()
+ {
+ var server = NewServer();
+ var opened = server.OpenAsync(new ProxyTunnelPayload
+ {
+ CorrelationId = "https-tunnel",
+ Url = "https://secure.test:8443/ws",
+ }, Capture).GetAwaiter().GetResult();
+
+ Assert.False(opened.Success);
+ Assert.Equal(ProxyFetchErrorCodes.BadRequest, opened.ErrorCode);
+ }
+
+ [Fact]
+ public async Task RejectsADuplicateTunnelIdOnTheEventServerGate()
+ {
+ var server = NewServer();
+ Assert.True((await server.OpenAsync(OpenPayload("/a"), Capture)).Success);
+
+ var duplicate = await server.OpenAsync(OpenPayload("/b"), Capture);
+ Assert.False(duplicate.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Error, duplicate.ErrorCode);
+ Assert.Contains("already active", duplicate.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task CapsConcurrentTunnelsToProtectTheEventServer()
+ {
+ _target = new WsTarget(upgrade: true);
+ var limits = TestLimits();
+ limits.EsMaxTunnels = 2;
+ var server = new ProxyTunnelServer(limits, message => { });
+
+ var first = await server.OpenAsync(OpenPayload("/a", "tunnel-a"), Capture);
+ Assert.True(first.Success, first.ErrorMessage);
+ var second = await server.OpenAsync(OpenPayload("/b", "tunnel-b"), Capture);
+ Assert.True(second.Success, second.ErrorMessage);
+
+ var third = await server.OpenAsync(OpenPayload("/c", "tunnel-c"), Capture);
+ Assert.False(third.Success);
+ Assert.Equal(ProxyFetchErrorCodes.Error, third.ErrorCode);
+ Assert.Contains("tunnel limit", third.ErrorMessage);
+ Assert.Equal(2, server.ActiveTunnels);
+ }
+
+ [Fact]
+ public async Task ReclaimsIdleTunnelsAndTellsTheClient()
+ {
+ var server = NewServer(); // IdleTimeoutMs = 150
+ Assert.True((await server.OpenAsync(OpenPayload("/ws"), Capture)).Success);
+
+ await Task.Delay(400);
+ server.ReclaimIdle();
+
+ Assert.Equal(0, server.ActiveTunnels);
+ var close = await WaitForClose();
+ Assert.Equal(_tunnelId, close.CorrelationId);
+ }
+
+ [Fact]
+ public async Task ReclaimsTheTunnelWhenTheTargetClosesItsSide()
+ {
+ _target = new WsTarget(upgrade: true, closeAfterHandshake: true);
+ var server = NewServer();
+
+ Assert.True((await server.OpenAsync(OpenPayload("/ws"), Capture)).Success);
+
+ var close = await WaitForClose();
+ Assert.Equal(_tunnelId, close.CorrelationId);
+ Assert.Equal(0, server.ActiveTunnels);
+ }
+
+ [Fact]
+ public async Task ClientCloseEndsTheTargetConnection()
+ {
+ var server = NewServer();
+ Assert.True((await server.OpenAsync(OpenPayload("/ws"), Capture)).Success);
+
+ server.OnClientClose(new ProxyTunnelPayload { CorrelationId = _tunnelId, ErrorMessage = "browser closed" });
+
+ Assert.Equal(0, server.ActiveTunnels);
+ // The target saw the socket drop: its echo read ended (journal records the EOF).
+ await WaitForJournal(_target, "connection closed");
+ }
+
+ // --- harness -------------------------------------------------------
+
+ private string _tunnelId = "tunnel-" + Guid.NewGuid().ToString("N");
+ private readonly List _toClient = new List();
+
+ private ProxyTunnelServer NewServer()
+ {
+ _target ??= new WsTarget(upgrade: true);
+ return new ProxyTunnelServer(TestLimits(), message => { });
+ }
+
+ private ProxyTunnelPayload OpenPayload(string path, string tunnelId = null)
+ {
+ return new ProxyTunnelPayload
+ {
+ CorrelationId = tunnelId ?? _tunnelId,
+ Url = $"http://127.0.0.1:{_target.Port}{path}",
+ Headers = new List
+ {
+ new ProxyFetchHeader { Name = "Upgrade", Value = "websocket" },
+ new ProxyFetchHeader { Name = "Connection", Value = "keep-alive, Upgrade" },
+ new ProxyFetchHeader { Name = "Sec-WebSocket-Version", Value = "13" },
+ new ProxyFetchHeader { Name = "Sec-WebSocket-Key", Value = "dGhlIHNhbXBsZSBub25jZQ==" },
+ new ProxyFetchHeader { Name = "Origin", Value = "http://127.0.0.1" },
+ },
+ };
+ }
+
+ private Task Capture(ProxyTunnelPayload payload)
+ {
+ lock (_toClient)
+ {
+ _toClient.Add(payload);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private async Task WaitForDataFrame()
+ {
+ for (var attempt = 0; attempt < 100; attempt++)
+ {
+ ProxyTunnelPayload frame;
+ lock (_toClient)
+ {
+ frame = _toClient.FirstOrDefault(p => p.DataBase64 != null);
+ }
+
+ if (frame != null)
+ {
+ return frame;
+ }
+
+ await Task.Delay(20);
+ }
+
+ throw new TimeoutException("no TunnelData frame reached the client callback");
+ }
+
+ private async Task WaitForClose()
+ {
+ for (var attempt = 0; attempt < 100; attempt++)
+ {
+ ProxyTunnelPayload close;
+ lock (_toClient)
+ {
+ close = _toClient.FirstOrDefault(p => p.DataBase64 == null && !string.IsNullOrEmpty(p.ErrorMessage));
+ }
+
+ if (close != null)
+ {
+ return close;
+ }
+
+ await Task.Delay(20);
+ }
+
+ throw new TimeoutException("no tunnel-close frame reached the client callback");
+ }
+
+ private static async Task WaitForJournal(WsTarget target, string needle)
+ {
+ for (var attempt = 0; attempt < 100; attempt++)
+ {
+ if (target.JournalSnapshot().Any(entry => entry.Contains(needle, StringComparison.Ordinal)))
+ {
+ return;
+ }
+
+ await Task.Delay(20);
+ }
+
+ throw new TimeoutException($"target journal never recorded '{needle}': [{string.Join(" | ", target.JournalSnapshot())}]");
+ }
+
+ private static int FreePort()
+ {
+ var probe = new TcpListener(IPAddress.Loopback, 0);
+ probe.Start();
+ var port = ((IPEndPoint)probe.LocalEndpoint).Port;
+ probe.Stop();
+ return port;
+ }
+
+ ///
+ /// In-process stand-in for a vendor websocket endpoint: answers the handshake with 101
+ /// (upgrade) or 401 (refuse), then echoes raw bytes until the socket drops.
+ ///
+ private sealed class WsTarget : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly bool _upgrade;
+ private readonly bool _closeAfterHandshake;
+ private readonly List _journal = new List();
+ private TcpClient _live;
+
+ internal int Port { get; }
+ internal string LastRequestHead { get; private set; }
+
+ internal WsTarget(bool upgrade, bool closeAfterHandshake = false)
+ {
+ _upgrade = upgrade;
+ _closeAfterHandshake = closeAfterHandshake;
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ _listener.Start();
+ Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
+ Task.Run(AcceptLoopAsync);
+ }
+
+ internal List JournalSnapshot()
+ {
+ lock (_journal)
+ {
+ return new List(_journal);
+ }
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ while (_listener.Server.IsBound)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync();
+ }
+ catch (Exception)
+ {
+ return;
+ }
+
+ _live = client;
+ _ = Task.Run(() => ServeAsync(client));
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client)
+ {
+ using (client)
+ using (var stream = client.GetStream())
+ {
+ var head = new StringBuilder();
+ var one = new byte[1];
+ while (true)
+ {
+ var read = await stream.ReadAsync(one, 0, 1);
+ if (read <= 0)
+ {
+ return;
+ }
+
+ head.Append((char)one[0]);
+ if (head.ToString().EndsWith("\r\n\r\n", StringComparison.Ordinal))
+ {
+ break;
+ }
+ }
+
+ LastRequestHead = head.ToString();
+ lock (_journal)
+ {
+ _journal.Add(head.ToString());
+ }
+
+ if (!_upgrade)
+ {
+ await stream.WriteAsync(Encoding.UTF8.GetBytes("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"));
+ return;
+ }
+
+ await stream.WriteAsync(Encoding.UTF8.GetBytes("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: Zm9vYmFy\r\n\r\n"));
+ if (_closeAfterHandshake)
+ {
+ lock (_journal)
+ {
+ _journal.Add("closed after handshake");
+ }
+
+ return;
+ }
+
+ var buffer = new byte[4096];
+ while (true)
+ {
+ var n = await stream.ReadAsync(buffer, 0, buffer.Length);
+ if (n <= 0)
+ {
+ lock (_journal)
+ {
+ _journal.Add("connection closed");
+ }
+
+ return;
+ }
+
+ await stream.WriteAsync(buffer, 0, n);
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ try { _live?.Close(); } catch { }
+ try { _listener.Stop(); } catch { }
+ }
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 96c863c..57d2558 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -45,6 +45,7 @@
+
diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs
index e2b34a4..dd405d3 100644
--- a/src/WebContent/ProxyFetchContract.cs
+++ b/src/WebContent/ProxyFetchContract.cs
@@ -305,6 +305,17 @@ internal static ProxyFetchLimits Default()
}
}
+ ///
+ /// Frame kinds inside the tunnel messages the Event Server pushes toward the client: raw
+ /// tunnel bytes versus the end-of-tunnel notice. The client's receiving side picks the
+ /// directional message id from this kind.
+ ///
+ internal static class ProxyTunnelFrameKinds
+ {
+ internal const string Data = "Data";
+ internal const string Close = "Close";
+ }
+
///
/// Ticket #57: the single payload shape for every websocket-tunnel message (open request,
/// open response, data frame in either direction, close in either direction). Which fields
@@ -318,6 +329,9 @@ public class ProxyTunnelPayload
/// The tunnel id; one Guid for the whole life of one tunnel.
public string CorrelationId;
+ /// Server-pushed frames: Data or Close (see ).
+ public string Kind;
+
/// Open request: the ws:// target url (http-scheme origin + path).
public string Url;
diff --git a/src/WebContent/ProxyTunnelServer.cs b/src/WebContent/ProxyTunnelServer.cs
new file mode 100644
index 0000000..e1e47fa
--- /dev/null
+++ b/src/WebContent/ProxyTunnelServer.cs
@@ -0,0 +1,523 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace WebContent
+{
+ ///
+ /// Ticket #57: the Event Server half of the websocket tunnel. Given the client's tunnel-open
+ /// (already allowlist-gated twice — client decision table and the ES allowlist union), it
+ /// replays the rebuilt origin-form handshake over a raw socket to the target, admits the
+ /// tunnel only when the target answers 101, and then pumps raw bytes in both directions
+ /// until either side closes. Tunnel bytes never enter the HTTP fetch/chunking path: each
+ /// direction rides its own directional message, base64 at the JSON edge only.
+ ///
+ /// Event-Server protection: a hard concurrency cap (a tunnel is a parked socket plus two
+ /// pumps), a per-message byte cap, and idle reclamation that also catches half-open tunnels
+ /// (one side gone without a clean close). The idle reaper disposes the socket, which breaks
+ /// the parked read and unwinds the pumps.
+ ///
+ internal sealed class ProxyTunnelServer
+ {
+ private sealed class Session
+ {
+ internal string Id;
+ internal TcpClient Socket;
+ internal NetworkStream Stream;
+ internal Func ToClient;
+ internal DateTime LastActivityUtc;
+ internal bool Closed;
+
+ // Client->target writes are serialized through this queue by one writer pump: two
+ // overlapping WriteAsync calls could interleave and corrupt the byte stream.
+ internal readonly ConcurrentQueue Outbound = new ConcurrentQueue();
+ internal TaskCompletionSource OutboundWaiter;
+ }
+
+ private readonly ProxyTunnelLimits _limits;
+ private readonly Action _log;
+ private readonly object _sync = new object();
+ private readonly Dictionary _sessions = new Dictionary(StringComparer.Ordinal);
+ private Timer _reaper;
+
+ internal ProxyTunnelServer(ProxyTunnelLimits limits, Action log)
+ {
+ _limits = limits ?? ProxyTunnelLimits.Default();
+ _log = log ?? (message => { });
+ _reaper = new Timer(_ => ReclaimIdle(), null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
+ }
+
+ internal int ActiveTunnels
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return _sessions.Count;
+ }
+ }
+ }
+
+ ///
+ /// Open one tunnel: connect, replay the handshake, and on 101 register the session and
+ /// start both pumps. is the caller-bound sender for this
+ /// tunnel's requester endpoint (unicast; a broadcast would leak another device's frames).
+ /// Never throws — every outcome is reported in the returned result.
+ ///
+ internal async Task OpenAsync(ProxyTunnelPayload open, Func toClient)
+ {
+ if (open == null || string.IsNullOrEmpty(open.CorrelationId) || toClient == null)
+ {
+ return OpenFailure(ProxyFetchErrorCodes.BadRequest, "The tunnel open carried no tunnel id or answer channel.");
+ }
+
+ Uri target;
+ if (!Uri.TryCreate(open.Url, UriKind.Absolute, out target) || target.Scheme != Uri.UriSchemeHttp)
+ {
+ return OpenFailure(ProxyFetchErrorCodes.BadRequest, $"The tunnel target '{open.Url}' is not an absolute http url; only plain ws:// origins (http scheme) are tunnelled.");
+ }
+
+ lock (_sync)
+ {
+ if (_sessions.ContainsKey(open.CorrelationId))
+ {
+ return OpenFailure(ProxyFetchErrorCodes.Error, "A tunnel with this id is already active.");
+ }
+
+ if (_sessions.Count >= _limits.EsMaxTunnels)
+ {
+ return OpenFailure(
+ ProxyFetchErrorCodes.Error,
+ $"The Event Server is at its websocket tunnel limit ({_limits.EsMaxTunnels} concurrent tunnels); close some proxied pages and retry.");
+ }
+ }
+
+ TcpClient socket = null;
+ try
+ {
+ socket = new TcpClient();
+ var connectTask = socket.ConnectAsync(target.IdnHost, target.Port);
+ if (await Task.WhenAny(connectTask, Task.Delay(_limits.ConnectTimeoutMs)) != connectTask)
+ {
+ CloseSocket(socket);
+ return OpenFailure(ProxyFetchErrorCodes.Unreachable, $"{OriginOf(target)}: no tunnel connection within {_limits.ConnectTimeoutMs} ms.");
+ }
+
+ await connectTask;
+ var stream = socket.GetStream();
+
+ var handshake = ProxyWebSocketHandshake.BuildTargetHandshake(target, open.Headers);
+ var writeTask = stream.WriteAsync(handshake, 0, handshake.Length);
+ if (await Task.WhenAny(writeTask, Task.Delay(_limits.ConnectTimeoutMs)) != writeTask)
+ {
+ CloseSocket(socket);
+ return OpenFailure(ProxyFetchErrorCodes.Timeout, $"{OriginOf(target)}: the handshake request was not accepted within {_limits.ConnectTimeoutMs} ms.");
+ }
+
+ await writeTask;
+
+ var buffer = new byte[_limits.HandshakeMaxBytes];
+ var received = 0;
+ int headLength;
+ while (!ProxyWebSocketHandshake.TryFindHandshakeEnd(buffer, received, out headLength))
+ {
+ var readTask = stream.ReadAsync(buffer, received, buffer.Length - received);
+ if (await Task.WhenAny(readTask, Task.Delay(_limits.HandshakeTimeoutMs)) != readTask)
+ {
+ CloseSocket(socket);
+ return OpenFailure(ProxyFetchErrorCodes.Timeout, $"{OriginOf(target)}: no websocket handshake answer within {_limits.HandshakeTimeoutMs} ms.");
+ }
+
+ var read = await readTask;
+ if (read <= 0)
+ {
+ CloseSocket(socket);
+ return OpenFailure(ProxyFetchErrorCodes.HandshakeRefused, $"{OriginOf(target)}: the target closed the connection without answering the websocket handshake.");
+ }
+
+ received += read;
+ if (received >= buffer.Length)
+ {
+ CloseSocket(socket);
+ return OpenFailure(ProxyFetchErrorCodes.HandshakeRefused, $"{OriginOf(target)}: the websocket handshake head is past the {_limits.HandshakeMaxBytes} byte cap.");
+ }
+ }
+
+ if (!ProxyWebSocketHandshake.IsUpgradeAccepted(buffer, headLength))
+ {
+ var status = ReadStatusCode(buffer, headLength);
+ CloseSocket(socket);
+ return OpenFailure(
+ ProxyFetchErrorCodes.HandshakeRefused,
+ $"{OriginOf(target)}: the target answered the websocket upgrade with {status}, not 101.",
+ status);
+ }
+
+ var session = new Session
+ {
+ Id = open.CorrelationId,
+ Socket = socket,
+ Stream = stream,
+ ToClient = toClient,
+ LastActivityUtc = DateTime.UtcNow,
+ };
+
+ lock (_sync)
+ {
+ _sessions[open.CorrelationId] = session;
+ }
+
+ _ = Task.Run(() => PumpClientToTargetAsync(session));
+ _ = Task.Run(() => PumpTargetToClientAsync(session));
+
+ // The handshake read may already have buffered target frame bytes past the head:
+ // they are the tunnel's first data, relayed before the open outcome is even sent.
+ if (received > headLength)
+ {
+ var initial = new byte[received - headLength];
+ Buffer.BlockCopy(buffer, headLength, initial, 0, initial.Length);
+ await SendDataAsync(session, initial);
+ }
+
+ var handshakeBytes = new byte[headLength];
+ Buffer.BlockCopy(buffer, 0, handshakeBytes, 0, headLength);
+ return new ProxyTunnelOpenOutcome
+ {
+ Success = true,
+ StatusCode = 101,
+ HandshakeBytes = handshakeBytes,
+ };
+ }
+ catch (SocketException socketException)
+ {
+ CloseSocket(socket);
+ return OpenFailure(ProxyFetchErrorCodes.Unreachable, $"{OriginOf(target)}: {socketException.Message}");
+ }
+ catch (Exception exception)
+ {
+ CloseSocket(socket);
+ for (var inner = exception; inner != null; inner = inner.InnerException)
+ {
+ if (inner is SocketException)
+ {
+ return OpenFailure(ProxyFetchErrorCodes.Unreachable, $"{OriginOf(target)}: {exception.Message}");
+ }
+ }
+
+ return OpenFailure(ProxyFetchErrorCodes.Error, $"{OriginOf(target)}: {exception.Message}");
+ }
+ }
+
+ /// Relay client bytes toward the target. Unknown ids (closed/timed-out tunnels) are logged, not thrown.
+ internal void OnClientData(ProxyTunnelPayload frame)
+ {
+ var session = Find(frame?.CorrelationId);
+ if (session == null)
+ {
+ return;
+ }
+
+ byte[] bytes;
+ try
+ {
+ bytes = string.IsNullOrEmpty(frame.DataBase64) ? new byte[0] : Convert.FromBase64String(frame.DataBase64);
+ }
+ catch (FormatException)
+ {
+ _log($"Tunnel {session.Id} data frame was not valid base64; the tunnel is closed.");
+ CloseSession(session, "the tunnel data frame was malformed", notifyClient: true);
+ return;
+ }
+
+ if (bytes.Length > _limits.FrameMaxBytes)
+ {
+ _log($"Tunnel {session.Id} data frame ({bytes.Length} bytes) is past the {_limits.FrameMaxBytes} byte tunnel-frame cap; the tunnel is closed.");
+ CloseSession(session, "a tunnel data frame exceeded the frame cap", notifyClient: true);
+ return;
+ }
+
+ if (bytes.Length == 0)
+ {
+ return;
+ }
+
+ session.LastActivityUtc = DateTime.UtcNow;
+ lock (_sync)
+ {
+ if (session.Closed)
+ {
+ return;
+ }
+
+ session.Outbound.Enqueue(bytes);
+ var waiter = session.OutboundWaiter;
+ session.OutboundWaiter = null;
+ waiter?.TrySetResult(bytes);
+ }
+ }
+
+ /// The client side ended the tunnel: drop it without echoing a close back.
+ internal void OnClientClose(ProxyTunnelPayload frame)
+ {
+ var session = Find(frame?.CorrelationId);
+ if (session != null)
+ {
+ CloseSession(session, frame.ErrorMessage ?? "the browser side closed the websocket", notifyClient: false);
+ }
+ }
+
+ /// Reclaim tunnels with no traffic in either direction for IdleTimeoutMs.
+ internal void ReclaimIdle()
+ {
+ List stale = null;
+ var cutoff = DateTime.UtcNow.AddMilliseconds(-_limits.IdleTimeoutMs);
+ lock (_sync)
+ {
+ if (_sessions.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var session in _sessions.Values)
+ {
+ if (!session.Closed && session.LastActivityUtc <= cutoff)
+ {
+ if (stale == null)
+ {
+ stale = new List();
+ }
+
+ stale.Add(session);
+ }
+ }
+ }
+
+ foreach (var session in stale ?? Enumerable.Empty())
+ {
+ _log($"Tunnel {session.Id} idle past {_limits.IdleTimeoutMs} ms; reclaimed.");
+ CloseSession(session, "the tunnel was idle past the proxy's reclaim timeout", notifyClient: true);
+ }
+ }
+
+ /// Drop every tunnel (plugin teardown). The messaging session dies alongside, so no close frames are sent.
+ internal void CloseAll()
+ {
+ List all;
+ lock (_sync)
+ {
+ all = new List(_sessions.Values);
+ }
+
+ foreach (var session in all)
+ {
+ CloseSession(session, "the proxy fetcher stopped", notifyClient: false);
+ }
+ }
+
+ internal void Dispose()
+ {
+ var reaper = _reaper;
+ _reaper = null;
+ reaper?.Dispose();
+ CloseAll();
+ }
+
+ private async Task PumpClientToTargetAsync(Session session)
+ {
+ try
+ {
+ while (true)
+ {
+ byte[] bytes;
+ lock (_sync)
+ {
+ if (session.Closed)
+ {
+ return;
+ }
+
+ if (!session.Outbound.TryDequeue(out bytes))
+ {
+ session.OutboundWaiter = new TaskCompletionSource();
+ }
+ }
+
+ if (bytes == null)
+ {
+ bytes = await session.OutboundWaiter.Task;
+ if (bytes == null)
+ {
+ return;
+ }
+
+ lock (_sync)
+ {
+ if (!session.Outbound.TryDequeue(out bytes))
+ {
+ continue;
+ }
+ }
+ }
+
+ session.LastActivityUtc = DateTime.UtcNow;
+ await session.Stream.WriteAsync(bytes, 0, bytes.Length);
+ }
+ }
+ catch (Exception exception)
+ {
+ if (!SessionIsClosed(session))
+ {
+ _log($"Tunnel {session.Id} target write failed: {exception.Message}");
+ CloseSession(session, "the target connection failed", notifyClient: true);
+ }
+ }
+ }
+
+ private async Task PumpTargetToClientAsync(Session session)
+ {
+ var buffer = new byte[_limits.FrameMaxBytes];
+ try
+ {
+ while (true)
+ {
+ var read = await session.Stream.ReadAsync(buffer, 0, buffer.Length);
+ if (read <= 0)
+ {
+ if (!SessionIsClosed(session))
+ {
+ _log($"Tunnel {session.Id}: the target closed the websocket.");
+ CloseSession(session, "the target closed the websocket", notifyClient: true);
+ }
+
+ return;
+ }
+
+ session.LastActivityUtc = DateTime.UtcNow;
+ var bytes = new byte[read];
+ Buffer.BlockCopy(buffer, 0, bytes, 0, read);
+ await SendDataAsync(session, bytes);
+ }
+ }
+ catch (Exception exception)
+ {
+ // Socket disposal (idle reclaim, client close, teardown) is the designed exit of
+ // a parked read; anything else is a real target-side fault.
+ if (!SessionIsClosed(session))
+ {
+ _log($"Tunnel {session.Id} target read failed: {exception.Message}");
+ CloseSession(session, "the target connection failed", notifyClient: true);
+ }
+ }
+ }
+
+ private static async Task SendDataAsync(Session session, byte[] bytes)
+ {
+ await session.ToClient(new ProxyTunnelPayload
+ {
+ CorrelationId = session.Id,
+ Kind = ProxyTunnelFrameKinds.Data,
+ DataBase64 = Convert.ToBase64String(bytes),
+ });
+ }
+
+ private void CloseSession(Session session, string reason, bool notifyClient)
+ {
+ lock (_sync)
+ {
+ if (session.Closed)
+ {
+ return;
+ }
+
+ session.Closed = true;
+ _sessions.Remove(session.Id);
+
+ var waiter = session.OutboundWaiter;
+ session.OutboundWaiter = null;
+ waiter?.TrySetResult(null);
+ }
+
+ CloseSocket(session.Socket);
+
+ if (notifyClient)
+ {
+ _ = SendCloseAsync(session, reason);
+ }
+ }
+
+ private async Task SendCloseAsync(Session session, string reason)
+ {
+ try
+ {
+ await session.ToClient(new ProxyTunnelPayload
+ {
+ CorrelationId = session.Id,
+ Kind = ProxyTunnelFrameKinds.Close,
+ ErrorMessage = reason,
+ });
+ }
+ catch (Exception exception)
+ {
+ _log($"Tunnel {session.Id} close notice not sent: {exception.Message}");
+ }
+ }
+
+ private Session Find(string tunnelId)
+ {
+ if (string.IsNullOrEmpty(tunnelId))
+ {
+ return null;
+ }
+
+ lock (_sync)
+ {
+ Session session;
+ return _sessions.TryGetValue(tunnelId, out session) && !session.Closed ? session : null;
+ }
+ }
+
+ private bool SessionIsClosed(Session session)
+ {
+ lock (_sync)
+ {
+ return session.Closed;
+ }
+ }
+
+ private static void CloseSocket(TcpClient socket)
+ {
+ try
+ {
+ socket?.Close();
+ }
+ catch (Exception)
+ {
+ }
+ }
+
+ private static int ReadStatusCode(byte[] buffer, int headLength)
+ {
+ var statusLine = Encoding.UTF8.GetString(buffer, 0, headLength).Split('\n')[0].Trim();
+ int code;
+ return int.TryParse(statusLine.Split(' ').ElementAtOrDefault(1), out code) ? code : 0;
+ }
+
+ private static string OriginOf(Uri target) => ProxyPolicy.CanonicalOrigin(target);
+
+ private static ProxyTunnelOpenOutcome OpenFailure(string errorCode, string message, int statusCode = 0)
+ {
+ return new ProxyTunnelOpenOutcome
+ {
+ Success = false,
+ StatusCode = statusCode,
+ ErrorCode = errorCode,
+ ErrorMessage = message,
+ };
+ }
+ }
+}
diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj
index 631e4fd..c0aa8b0 100644
--- a/src/WebContent/WebContent.csproj
+++ b/src/WebContent/WebContent.csproj
@@ -122,6 +122,7 @@
+
From 891fe372a1091a109a6a4d43aaa371c229a901ca Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sun, 1 Nov 2026 01:58:12 -0700
Subject: [PATCH 056/187] chore: Add and apply PSScriptAnalyzer formatting
rules
---
.vscode/settings.json | 16 ++++-
DownloadSetupPrerequites.ps1 | 9 +--
PSScriptAnalyzerSettings.psd1 | 49 +++++++++++++
install.ps1 | 4 +-
run-ux.ps1 | 128 +++++++++++++++++-----------------
updatesetupversion.ps1 | 6 +-
verify.ps1 | 6 +-
7 files changed, 139 insertions(+), 79 deletions(-)
create mode 100644 PSScriptAnalyzerSettings.psd1
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 013007b..1d499a9 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,3 +1,15 @@
{
- "dotnet.preferCSharpExtension": true
-}
\ No newline at end of file
+ "powershell.codeFormatting.preset": "OTBS",
+ "powershell.scriptAnalysis.settingsPath": "PSScriptAnalyzerSettings.psd1",
+ "files.encoding": "utf8",
+ "[powershell]": {
+ "editor.tabSize": 4,
+ "editor.insertSpaces": true,
+ "editor.formatOnSave": true
+ },
+ "[csharp]": {
+ "editor.tabSize": 4,
+ "editor.insertSpaces": true,
+ "editor.formatOnSave": true
+ }
+}
diff --git a/DownloadSetupPrerequites.ps1 b/DownloadSetupPrerequites.ps1
index 051be23..5ddfbfe 100644
--- a/DownloadSetupPrerequites.ps1
+++ b/DownloadSetupPrerequites.ps1
@@ -31,8 +31,7 @@ foreach ($dependency in $dependencies) {
$argList += $MyInvocation.UnboundArguments
Start-Process PowerShell.exe -Verb Runas -WorkingDirectory $pwd -ArgumentList $argList
return
- }
- else {
+ } else {
throw "You must be administrator to run this script"
}
}
@@ -47,16 +46,14 @@ foreach ($dependency in $dependencies) {
$argList += $MyInvocation.UnboundArguments
Start-Process PowerShell.exe -Verb Runas -WorkingDirectory $pwd -ArgumentList $argList
return
- }
- else {
+ } else {
throw "You must be administrator to run this script"
}
}
Write-Host "Downloading $($dependency.Name) to $($dependency.Destination)"
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $dependency.Url -OutFile $dependency.Destination
- }
- else {
+ } else {
Write-Host "$($dependency.Name) already available at $($dependency.Destination)"
}
}
diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1
new file mode 100644
index 0000000..85eda87
--- /dev/null
+++ b/PSScriptAnalyzerSettings.psd1
@@ -0,0 +1,49 @@
+@{
+ Rules = @{
+ PSPlaceOpenBrace = @{
+ Enable = $true
+ OnSameLine = $true
+ NewLineAfter = $true
+ IgnoreOneLineBlock = $false
+ }
+
+ PSPlaceCloseBrace = @{
+ Enable = $true
+ NewLineBefore = $false
+ NewLineAfter = $false
+ IgnoreOneLineBlock = $false
+ }
+
+ PSUseConsistentIndentation = @{
+ Enable = $true
+ IndentationSize = 4
+ Kind = 'space'
+ # Extension default (powershell.codeFormatting.pipelineIndentation =
+ # 'noIndentation'); 'IncreaseIndentationForFirstPipeline' is the
+ # PSScriptAnalyzer default if you prefer indented pipelines.
+ PipelineIndentation = 'NoIndentation'
+ }
+
+ PSUseConsistentWhitespace = @{
+ Enable = $true
+ CheckInnerBrace = $true
+ CheckOpenBrace = $true
+ CheckOpenParen = $true
+ CheckOperator = $true
+ CheckPipe = $true
+ CheckPipeForRedundantWhitespace = $false
+ CheckSeparator = $true
+ CheckParameter = $false
+ IgnoreAssignmentOperatorInsideHashTable = $true
+ }
+
+ PSAlignAssignmentStatement = @{
+ Enable = $true
+ CheckHashtable = $true
+ AlignHashtableKvpWithInterveningComment = $true
+ CheckEnum = $true
+ AlignEnumMemberWithInterveningComment = $true
+ IncludeValuelessEnumMembers = $true
+ }
+ }
+}
diff --git a/install.ps1 b/install.ps1
index 84cc59e..264e18f 100644
--- a/install.ps1
+++ b/install.ps1
@@ -1,6 +1,6 @@
Get-Date
& appwiz.cpl
-ii C:\repos\WebContentPlugin
-ii 'C:\Program Files\'
+Invoke-Item C:\repos\WebContentPlugin
+Invoke-Item 'C:\Program Files\'
$msiPath = (Get-ChildItem -Path C:\repos\WebContentPlugin\output\Setup\*.msi).FullName
##msiexec /i $msiPath /q /le c:\repos\WebContentPlugin\output\Setup\install.log
\ No newline at end of file
diff --git a/run-ux.ps1 b/run-ux.ps1
index 56e3f62..dbb6a9e 100644
--- a/run-ux.ps1
+++ b/run-ux.ps1
@@ -5,22 +5,22 @@
# promoted (copied) into docs/assets/images/ under their final names. Pass
# -NoPromote to skip that and leave the artifacts for inspection instead.
param(
- [Parameter()]
- [ValidateSet('ux', 'ux-recon', 'ux-screenshots')]
- [string]
- $Category = 'ux',
+ [Parameter()]
+ [ValidateSet('ux', 'ux-recon', 'ux-screenshots')]
+ [string]
+ $Category = 'ux',
- [Parameter()]
- [string]
- $Filter = '',
+ [Parameter()]
+ [string]
+ $Filter = '',
- [Parameter()]
- [string]
- $Configuration = 'Debug',
+ [Parameter()]
+ [string]
+ $Configuration = 'Debug',
- [Parameter()]
- [switch]
- $NoPromote
+ [Parameter()]
+ [switch]
+ $NoPromote
)
$ErrorActionPreference = 'Stop'
@@ -28,69 +28,69 @@ $repo = Split-Path -Parent $PSCommandPath
Push-Location $repo
try {
- $results = Join-Path $repo 'artifacts/ux/test-results'
- New-Item -ItemType Directory -Path $results -Force | Out-Null
+ $results = Join-Path $repo 'artifacts/ux/test-results'
+ New-Item -ItemType Directory -Path $results -Force | Out-Null
- $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=0'
+ $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=0'
- $dotnetArgs = @(
- 'test',
- 'tests/ux/WebContent.UxTests/WebContent.UxTests.csproj',
- '-c', $Configuration,
- '--logger', 'console;verbosity=normal',
- '--results-directory', $results
- )
+ $dotnetArgs = @(
+ 'test',
+ 'tests/ux/WebContent.UxTests/WebContent.UxTests.csproj',
+ '-c', $Configuration,
+ '--logger', 'console;verbosity=normal',
+ '--results-directory', $results
+ )
- if ($Filter) {
- $dotnetArgs += @('--filter', $Filter)
- } else {
- $dotnetArgs += @('--filter', "Category=$Category")
- }
+ if ($Filter) {
+ $dotnetArgs += @('--filter', $Filter)
+ } else {
+ $dotnetArgs += @('--filter', "Category=$Category")
+ }
- $runStart = Get-Date
- & dotnet @dotnetArgs
- $exit = $LASTEXITCODE
+ $runStart = Get-Date
+ & dotnet @dotnetArgs
+ $exit = $LASTEXITCODE
- if ($exit -eq 0 -and -not $NoPromote) {
- # Promote only files written by THIS run (artifact dirs touched after $runStart),
- # newest wins per name, so running one test at a time still promotes correctly.
- $map = [ordered]@{
- 'readme-sc-01-view.png' = 'screenshot1.png'
- 'readme-sc-02-properties.png' = 'properties.png'
- 'readme-mc-01-workspace.png' = 'mc-workspace.png'
- 'readme-mc-02-role-security.png' = 'mc-role-security.png'
- }
- $imagesDir = Join-Path $repo 'docs/assets/images'
- $runDirs = @(Get-ChildItem (Join-Path $repo 'artifacts/ux') -Directory |
- Where-Object { $_.Name -ne 'test-results' -and $_.LastWriteTime -ge $runStart })
+ if ($exit -eq 0 -and -not $NoPromote) {
+ # Promote only files written by THIS run (artifact dirs touched after $runStart),
+ # newest wins per name, so running one test at a time still promotes correctly.
+ $map = [ordered]@{
+ 'readme-sc-01-view.png' = 'screenshot1.png'
+ 'readme-sc-02-properties.png' = 'properties.png'
+ 'readme-mc-01-workspace.png' = 'mc-workspace.png'
+ 'readme-mc-02-role-security.png' = 'mc-role-security.png'
+ }
+ $imagesDir = Join-Path $repo 'docs/assets/images'
+ $runDirs = @(Get-ChildItem (Join-Path $repo 'artifacts/ux') -Directory |
+ Where-Object { $_.Name -ne 'test-results' -and $_.LastWriteTime -ge $runStart })
- foreach ($src in $map.Keys) {
- $found = $runDirs |
- ForEach-Object { Get-ChildItem $_.FullName -Filter $src -File -ErrorAction SilentlyContinue } |
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
- if ($found) {
- Copy-Item $found.FullName (Join-Path $imagesDir $map[$src]) -Force
- Write-Host "promoted: $($found.Name) -> docs/assets/images/$($map[$src])"
- } else {
- Write-Warning "no '$src' produced by this run; docs/assets/images/$($map[$src]) left untouched"
- }
+ foreach ($src in $map.Keys) {
+ $found = $runDirs |
+ ForEach-Object { Get-ChildItem $_.FullName -Filter $src -File -ErrorAction SilentlyContinue } |
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if ($found) {
+ Copy-Item $found.FullName (Join-Path $imagesDir $map[$src]) -Force
+ Write-Host "promoted: $($found.Name) -> docs/assets/images/$($map[$src])"
+ } else {
+ Write-Warning "no '$src' produced by this run; docs/assets/images/$($map[$src]) left untouched"
+ }
+ }
}
- }
- $latest = Get-ChildItem (Join-Path $repo 'artifacts/ux') -Directory -ErrorAction SilentlyContinue |
+ $latest = Get-ChildItem (Join-Path $repo 'artifacts/ux') -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne 'test-results' } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
- if ($latest) {
- Write-Host "UX artifacts: $($latest.FullName)"
- Get-ChildItem $latest.FullName |
- Sort-Object LastWriteTime |
- Select-Object Name, Length, LastWriteTime |
- Out-Host
- }
+ if ($latest) {
+ Write-Host "UX artifacts: $($latest.FullName)"
+ Get-ChildItem $latest.FullName |
+ Sort-Object LastWriteTime |
+ Select-Object Name, Length, LastWriteTime |
+ Out-Host
+ }
- exit $exit
+ exit $exit
} finally {
- Pop-Location
+ Pop-Location
}
diff --git a/updatesetupversion.ps1 b/updatesetupversion.ps1
index aee3790..749b354 100644
--- a/updatesetupversion.ps1
+++ b/updatesetupversion.ps1
@@ -1,4 +1,8 @@
-$projectRoot = if ([string]::IsNullOrEmpty($PSScriptRoot)) { $PWD } else { $PSScriptRoot }
+$projectRoot = if ([string]::IsNullOrEmpty($PSScriptRoot)) {
+ $PWD
+} else {
+ $PSScriptRoot
+}
$nbgv = dotnet nbgv get-version -f json | ConvertFrom-Json
$vdprojPath = (Resolve-Path "$projectRoot\Setup\Setup.vdproj" -ErrorAction Stop).Path
$vdproj = Get-Content $vdprojPath -Raw
diff --git a/verify.ps1 b/verify.ps1
index ff59657..e6ca396 100644
--- a/verify.ps1
+++ b/verify.ps1
@@ -31,8 +31,7 @@ if ($IncludePluginBuild) {
if (-not (Test-Path -LiteralPath $msbuild)) {
Write-Host 'MSBuild not found (PATH or VS2022 Community default). Install VS2022 or add msbuild to PATH.' -ForegroundColor Red
$gates.Add([pscustomobject]@{ Name = 'Plugin build'; ExitCode = 1 })
- }
- else {
+ } else {
& $msbuild (Join-Path $repoRoot 'src/WebContent/WebContent.csproj') /t:Build /p:Configuration=Debug /p:Platform=x64 /v:minimal /nologo
# PostBuildEvent sbom generation is warning-free and self-overwriting (-D true); exit code is authoritative.
$gates.Add([pscustomobject]@{ Name = 'Plugin build'; ExitCode = $LASTEXITCODE })
@@ -44,8 +43,7 @@ $failed = $false
foreach ($gate in $gates) {
if ($gate.ExitCode -eq 0) {
Write-Host ("PASS {0} (exit 0)" -f $gate.Name) -ForegroundColor Green
- }
- else {
+ } else {
Write-Host ("FAIL {0} (exit {1})" -f $gate.Name, $gate.ExitCode) -ForegroundColor Red
$failed = $true
}
From d0bf98c65f667ba81d004c24acfa35eb3529f32d Mon Sep 17 00:00:00 2001
From: joshooaj <7669972+joshooaj@users.noreply.github.com>
Date: Sun, 1 Nov 2026 01:39:34 -0800
Subject: [PATCH 057/187] Wire websocket tunnels through both messaging hosts
(#57)
- ProxyTunnelCorrelation: pure client-side tunnel registry (ordered pumps,
open deadline, idle reclaim, orphan-ack cleanup) with a 14-test seam.
- ProxyFetchClient: OpenTunnelAsync + MessagingTunnelHandle, three client-
bound tunnel filters, ClientMaxTunnels cap, periodic sweep, teardown closes
every tunnel with an explicit notice.
- ProxyFetchBackgroundPlugin: ProxyTunnelServer lifecycle + TunnelOpen/Data/
Close filters, allowlist re-gate on the ES, per-tunnel unicast sender,
thread-pool opens so the receive thread never blocks.
- LoopbackProxyListener passes the openTunnel seam; PluginDefinedMessageIds
and the ES load guard pin the six directional ids.
- ProxyForwardCodec: browsers send ws:// upgrades as absolute ws:// targets
(RFC 6455 5.1) - rebase to the http origin twin instead of refusing.
---
.../EventServerLoadGuardTests.cs | 2 +
.../ProxyForwardPolicyTests.cs | 19 +-
.../ProxyTunnelCorrelationTests.cs | 234 ++++++++++++
src/WebContent.Tests/WebContent.Tests.csproj | 1 +
.../Client/LoopbackProxyListener.cs | 24 +-
src/WebContent/ProxyFetchBackgroundPlugin.cs | 215 +++++++++++
src/WebContent/ProxyFetchClient.cs | 350 +++++++++++++++++-
src/WebContent/ProxyForwardCodec.cs | 14 +
src/WebContent/ProxyTunnelCorrelation.cs | 330 +++++++++++++++++
src/WebContent/ProxyTunnelServer.cs | 19 +-
src/WebContent/WebContent.csproj | 1 +
src/WebContent/WebContentPluginDefinition.cs | 6 +
12 files changed, 1211 insertions(+), 4 deletions(-)
create mode 100644 src/WebContent.Tests/ProxyTunnelCorrelationTests.cs
create mode 100644 src/WebContent/ProxyTunnelCorrelation.cs
diff --git a/src/WebContent.Tests/EventServerLoadGuardTests.cs b/src/WebContent.Tests/EventServerLoadGuardTests.cs
index 66f2eea..51f91eb 100644
--- a/src/WebContent.Tests/EventServerLoadGuardTests.cs
+++ b/src/WebContent.Tests/EventServerLoadGuardTests.cs
@@ -43,6 +43,8 @@ public void DefinitionOverridesBackgroundPluginsAndRegistersTheProxyMessageIds()
Assert.Matches(@"override\s+List\s+PluginDefinedMessageIds", definition);
Assert.Matches("FetchRequestId", definition);
Assert.Matches("FetchResponseId", definition);
+ Assert.Matches("TunnelOpenRequestId", definition);
+ Assert.Matches("TunnelCloseFromTargetId", definition);
}
[Fact]
diff --git a/src/WebContent.Tests/ProxyForwardPolicyTests.cs b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
index 6ba9abb..1e277e2 100644
--- a/src/WebContent.Tests/ProxyForwardPolicyTests.cs
+++ b/src/WebContent.Tests/ProxyForwardPolicyTests.cs
@@ -187,7 +187,7 @@ public void NestedConnectInsideATerminatedTunnelIsRefused()
public void AllowlistedWebSocketUpgradeTunnelsInsteadOfForwarding()
{
var head =
- "GET http://10.0.0.5:8080/ws/live HTTP/1.1\r\n" +
+ "GET ws://10.0.0.5:8080/ws/live HTTP/1.1\r\n" +
"Host: 10.0.0.5:8080\r\n" +
"Connection: keep-alive, Upgrade\r\n" +
"Upgrade: websocket\r\n" +
@@ -203,6 +203,23 @@ public void AllowlistedWebSocketUpgradeTunnelsInsteadOfForwarding()
Assert.Null(rejectReason);
}
+ [Fact]
+ public void RefusesAbsoluteWssUpgradeAsUnTunnelable()
+ {
+ var head =
+ "GET wss://10.0.0.5:8080/ws/live HTTP/1.1\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
+ "\r\n";
+
+ var decision = ProxyForwardPolicy.Decide(Allowlist, head, out _, out var rejectReason);
+
+ Assert.Equal(ProxyForwardDisposition.WebSocketTunnelRefused, decision);
+ Assert.Contains("wss", rejectReason);
+ }
+
[Fact]
public void RefusesWebSocketUpgradeToOriginsNoWorkspaceAllowlists()
{
diff --git a/src/WebContent.Tests/ProxyTunnelCorrelationTests.cs b/src/WebContent.Tests/ProxyTunnelCorrelationTests.cs
new file mode 100644
index 0000000..c7224f2
--- /dev/null
+++ b/src/WebContent.Tests/ProxyTunnelCorrelationTests.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace WebContent.Tests
+{
+ ///
+ /// Ticket #57: the client-side tunnel correlator is the pure registry between the messaging
+ /// receive thread and the browser connection pumps — open acks, ordered data queues, close
+ /// notices, open deadlines, and idle reclaim. Messaging itself (ProxyFetchClient) is thin
+ /// glue over this; everything that can go wrong about ordering and lifetime lives here.
+ ///
+ public class ProxyTunnelCorrelationTests
+ {
+ private static ProxyTunnelPayload OpenAck(string id, bool success)
+ {
+ return new ProxyTunnelPayload { CorrelationId = id, Success = success, StatusCode = success ? 101 : 0 };
+ }
+
+ // ---- registration and open ----
+
+ [Fact]
+ public async Task Register_then_await_open_completes_with_the_ack_outcome()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ Assert.True(registry.TryRegister("t1"));
+
+ var waiting = registry.AwaitOpenAsync("t1");
+ Assert.False(waiting.IsCompleted);
+
+ registry.CompleteOpen("t1", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+
+ var outcome = await waiting;
+ Assert.True(outcome.Success);
+ Assert.Equal(101, outcome.StatusCode);
+ }
+
+ [Fact]
+ public void Register_refuses_a_duplicate_id()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ Assert.True(registry.TryRegister("t1"));
+ Assert.False(registry.TryRegister("t1"));
+ }
+
+ [Fact]
+ public void Open_ack_for_an_unknown_id_closes_the_handle_it_came_with()
+ {
+ // The client abandoned the open (deadline) while the Event Server was still
+ // connecting: the late ack must not orphan a live target socket.
+ var registry = new ProxyTunnelCorrelation();
+ var handle = new FakeHandle();
+
+ registry.CompleteOpen("gone", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101, Handle = handle });
+
+ Assert.True(handle.ClosedLocally);
+ }
+
+ [Fact]
+ public async Task Await_open_on_an_unknown_id_returns_null()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ Assert.Null(await registry.AwaitOpenAsync("nope"));
+ }
+
+ // ---- data ordering ----
+
+ [Fact]
+ public async Task Target_data_queues_in_order_before_the_pump_attaches()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("t1");
+ registry.CompleteOpen("t1", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+
+ registry.OnTargetData("t1", new byte[] { 1, 2 });
+ registry.OnTargetData("t1", new byte[] { 3 });
+
+ Assert.Equal(new byte[] { 1, 2 }, await registry.NextDataAsync("t1"));
+ Assert.Equal(new byte[] { 3 }, await registry.NextDataAsync("t1"));
+ }
+
+ [Fact]
+ public async Task A_waiting_pump_gets_the_next_bytes_immediately()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("t1");
+ registry.CompleteOpen("t1", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+
+ var waiting = registry.NextDataAsync("t1");
+ Assert.False(waiting.IsCompleted);
+
+ registry.OnTargetData("t1", new byte[] { 9 });
+
+ Assert.Equal(new byte[] { 9 }, await waiting);
+ }
+
+ [Fact]
+ public void Data_for_an_unknown_tunnel_is_ignored()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.OnTargetData("gone", new byte[] { 1 }); // must not throw
+ }
+
+ // ---- close paths ----
+
+ [Fact]
+ public async Task Target_close_ends_the_pump_with_null()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("t1");
+ registry.CompleteOpen("t1", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+
+ var waiting = registry.NextDataAsync("t1");
+ registry.OnTargetClose("t1", "target went away");
+
+ Assert.Null(await waiting);
+ Assert.Null(await registry.NextDataAsync("t1"));
+ Assert.True(registry.IsClosed("t1"));
+ }
+
+ [Fact]
+ public void Local_end_closes_the_session_and_later_data_is_ignored()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("t1");
+ registry.CompleteOpen("t1", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+
+ Assert.True(registry.EndLocal("t1"));
+ registry.OnTargetData("t1", new byte[] { 1 }); // after local end: dropped
+ Assert.True(registry.IsClosed("t1"));
+
+ // The id is released, not resurrected: EndLocal removes it from the registry, so
+ // late frames are unknown-id traffic, and the same id can be reused.
+ Assert.True(registry.TryRegister("t1"));
+ }
+
+ [Fact]
+ public async Task Open_failure_completes_the_waiter_with_the_failure()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("t1");
+
+ var waiting = registry.AwaitOpenAsync("t1");
+ registry.CompleteOpen("t1", new ProxyTunnelOpenOutcome { Success = false, ErrorCode = "HandshakeRefused", ErrorMessage = "401" });
+
+ var outcome = await waiting;
+ Assert.NotNull(outcome);
+ Assert.False(outcome.Success);
+ Assert.Equal("HandshakeRefused", outcome.ErrorCode);
+ Assert.True(registry.IsClosed("t1"));
+ }
+
+ // ---- lifetime sweeps ----
+
+ [Fact]
+ public void Reclaim_idle_closes_only_the_stalled_sessions()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("old");
+ registry.CompleteOpen("old", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+ registry.TryRegister("fresh");
+ registry.CompleteOpen("fresh", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+
+ // Backdate the old session's activity past the idle bound.
+ registry.DebugSetLastActivity("old", DateTime.UtcNow - TimeSpan.FromMinutes(10));
+
+ var reclaimed = registry.ReclaimIdle(TimeSpan.FromMinutes(5));
+
+ Assert.Contains("old", reclaimed);
+ Assert.DoesNotContain("fresh", reclaimed);
+ Assert.True(registry.IsClosed("old"));
+ Assert.False(registry.IsClosed("fresh"));
+ }
+
+ [Fact]
+ public void Any_traffic_moves_the_idle_deadline()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("t1");
+ registry.CompleteOpen("t1", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+ registry.DebugSetLastActivity("t1", DateTime.UtcNow - TimeSpan.FromMinutes(10));
+
+ registry.OnTargetData("t1", new byte[] { 1 });
+
+ var reclaimed = registry.ReclaimIdle(TimeSpan.FromMinutes(5));
+ Assert.Empty(reclaimed);
+ }
+
+ [Fact]
+ public void Close_all_empties_the_registry()
+ {
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("a");
+ registry.CompleteOpen("a", new ProxyTunnelOpenOutcome { Success = true, StatusCode = 101 });
+ registry.TryRegister("b");
+
+ registry.CloseAll("client shut down");
+
+ Assert.Equal(0, registry.Count);
+ Assert.True(registry.IsClosed("a"));
+ }
+
+ [Fact]
+ public void Half_open_tunnels_are_reclaimed_on_the_open_deadline()
+ {
+ // Registered, never acked: the half-open case the acceptance criteria call out.
+ var registry = new ProxyTunnelCorrelation();
+ registry.TryRegister("half");
+ registry.DebugSetLastActivity("half", DateTime.UtcNow - TimeSpan.FromMinutes(10));
+
+ var reclaimed = registry.ReclaimIdle(TimeSpan.FromMinutes(5));
+
+ Assert.Contains("half", reclaimed);
+ Assert.True(registry.IsClosed("half"));
+ }
+
+ private sealed class FakeHandle : ProxyTunnelHandle
+ {
+ internal bool ClosedLocally;
+
+ internal override bool IsClosed => ClosedLocally;
+
+ internal override Task SendAsync(byte[] bytes) => Task.CompletedTask;
+
+ internal override Task NextDataAsync() => Task.FromResult(null);
+
+ internal override void Close(string reason)
+ {
+ ClosedLocally = true;
+ }
+ }
+ }
+}
diff --git a/src/WebContent.Tests/WebContent.Tests.csproj b/src/WebContent.Tests/WebContent.Tests.csproj
index 57d2558..a5be8ad 100644
--- a/src/WebContent.Tests/WebContent.Tests.csproj
+++ b/src/WebContent.Tests/WebContent.Tests.csproj
@@ -46,6 +46,7 @@
+
diff --git a/src/WebContent/Client/LoopbackProxyListener.cs b/src/WebContent/Client/LoopbackProxyListener.cs
index 33f6469..61f39d1 100644
--- a/src/WebContent/Client/LoopbackProxyListener.cs
+++ b/src/WebContent/Client/LoopbackProxyListener.cs
@@ -92,6 +92,27 @@ private static Task RequestLeafAsync(string host, int
return client.RequestLeafAsync(host, port, subjectPublicKeyInfoDer);
}
+ ///
+ /// Ticket #57: the websocket-tunnel channel over the same messaging client the fetches
+ /// use. Without a live client the upgrade fails as a plain 502 - never smuggled into
+ /// the HTTP fetch path.
+ ///
+ private static Task OpenTunnelAsync(ProxyForwardedRequest request)
+ {
+ var client = ProxyFetchClient.Active;
+ if (client == null)
+ {
+ return Task.FromResult(new ProxyTunnelOpenOutcome
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy fetch client is not connected to the Event Server.",
+ });
+ }
+
+ return client.OpenTunnelAsync(request);
+ }
+
private async Task AcceptLoopAsync()
{
var listener = _listener;
@@ -132,7 +153,8 @@ await ProxyForwardConnection.HandleAsync(
_canonicalTargets,
_fetch,
message => EnvironmentManager.Instance.Log(true, nameof(LoopbackProxyListener), $"Proxy listener on port {Port}: {message}"),
- _tls.AcceptTunnelAsync);
+ _tls.AcceptTunnelAsync,
+ OpenTunnelAsync);
}
}
catch (Exception exception)
diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs
index f1c434c..9697ee4 100644
--- a/src/WebContent/ProxyFetchBackgroundPlugin.cs
+++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs
@@ -32,9 +32,13 @@ internal sealed class ProxyFetchBackgroundPlugin : BackgroundPlugin
private MessageCommunication _messageCommunication;
private object _requestFilterRegistration;
private object _leafFilterRegistration;
+ private object _tunnelOpenFilterRegistration;
+ private object _tunnelDataFrameFilterRegistration;
+ private object _tunnelCloseFrameFilterRegistration;
private SemaphoreSlim _fetchSlots;
private ProxyServerAllowlist _allowlist;
private ProxyLeafIssuer _leafIssuer;
+ private ProxyTunnelServer _tunnels;
///
/// ADR-0003: the deployment CA is loaded/created lazily on the first leaf request and
@@ -91,6 +95,24 @@ public override void Init()
OnProxyLeafRequest,
new CommunicationIdFilter(ProxyMessageIds.LeafRequestId));
+ // Ticket #57: the Event Server half of the websocket CONNECT tunnel. The client
+ // has already allowlist-gated the upgrade; this side re-gates against its own
+ // allowlist union, replays the handshake to the target over a raw socket, admits
+ // only a 101, and pumps raw bytes both directions. The open runs on the thread
+ // pool (connect + handshake is seconds long), so the receive thread stays free.
+ _tunnels = new ProxyTunnelServer(
+ ProxyTunnelLimits.Default(),
+ message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message));
+ _tunnelOpenFilterRegistration = _messageCommunication.RegisterCommunicationFilter(
+ OnProxyTunnelOpen,
+ new CommunicationIdFilter(ProxyMessageIds.TunnelOpenRequestId));
+ _tunnelDataFrameFilterRegistration = _messageCommunication.RegisterCommunicationFilter(
+ OnProxyTunnelDataFrame,
+ new CommunicationIdFilter(ProxyMessageIds.TunnelDataToTargetId));
+ _tunnelCloseFrameFilterRegistration = _messageCommunication.RegisterCommunicationFilter(
+ OnProxyTunnelCloseFrame,
+ new CommunicationIdFilter(ProxyMessageIds.TunnelCloseToTargetId));
+
EnvironmentManager.Instance.Log(
false,
nameof(ProxyFetchBackgroundPlugin),
@@ -134,6 +156,37 @@ public override void Close()
_requestFilterRegistration = null;
_leafFilterRegistration = null;
_leafIssuer = null;
+
+ // Tear tunnels down while messaging is still alive: each close rides a message
+ // notice back to its requester, and the sockets must not outlive the plugin.
+ var tunnels = _tunnels;
+ _tunnels = null;
+ tunnels?.Dispose();
+
+ if (_messageCommunication != null)
+ {
+ foreach (var registration in new[] { _tunnelOpenFilterRegistration, _tunnelDataFrameFilterRegistration, _tunnelCloseFrameFilterRegistration })
+ {
+ if (registration == null)
+ {
+ continue;
+ }
+
+ try
+ {
+ _messageCommunication.UnRegisterCommunicationFilter(registration);
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy tunnel filter detach failed: {exception.Message}");
+ }
+ }
+ }
+
+ _tunnelOpenFilterRegistration = null;
+ _tunnelDataFrameFilterRegistration = null;
+ _tunnelCloseFrameFilterRegistration = null;
+
_messageCommunication = null;
// Deliberately no MessageCommunicationManager.Stop: the messaging session is
@@ -415,6 +468,168 @@ private Authority ResolveDeploymentAuthority()
}
}
+ ///
+ /// Ticket #57: answer one tunnel-open. Re-gated against this process's own allowlist
+ /// union (the client's decision table is the first gate, not the only one), then the open
+ /// runs on the thread pool: connect + handshake replay is seconds long and must never
+ /// block the messaging receive thread. Frames for this tunnel unicast to the requester
+ /// endpoint - a null destination would broadcast one device's websocket frames to every
+ /// subscribed host.
+ ///
+ private object OnProxyTunnelOpen(Message message, FQID destination, FQID source)
+ {
+ var requester = ResolveRequester(source, message);
+
+ var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data);
+ if (payload == null || string.IsNullOrEmpty(payload.CorrelationId))
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Tunnel open carried no usable payload; refused.");
+ return null;
+ }
+
+ var communication = _messageCommunication;
+ var tunnels = _tunnels;
+ if (communication == null || tunnels == null)
+ {
+ TransmitTunnelOpenAck(communication, requester, payload.CorrelationId, new ProxyTunnelOpenOutcome
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = "The proxy tunnel server is not ready.",
+ });
+ return null;
+ }
+
+ Uri target;
+ string allowReason = null;
+ if (!Uri.TryCreate(payload.Url, UriKind.Absolute, out target)
+ || target.Scheme != Uri.UriSchemeHttp
+ || _allowlist == null
+ || !_allowlist.IsAllowed(target, out allowReason))
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Tunnel open {payload.CorrelationId} refused: {(allowReason ?? "the tunnel target was not an allowlisted http origin")}.");
+ TransmitTunnelOpenAck(communication, requester, payload.CorrelationId, new ProxyTunnelOpenOutcome
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.NotAllowed,
+ ErrorMessage = allowReason ?? "The tunnel target is not an allowlisted http origin.",
+ });
+ return null;
+ }
+
+ if (requester == null)
+ {
+ // Nowhere to unicast the frame stream: a broadcast tunnel leaks every frame to
+ // every subscriber, so the tunnel must not open at all.
+ return null;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ Func toClient = frame =>
+ {
+ var id = frame.Kind == ProxyTunnelFrameKinds.Close
+ ? ProxyMessageIds.TunnelCloseFromTargetId
+ : ProxyMessageIds.TunnelDataFromTargetId;
+ try
+ {
+ communication.TransmitMessage(new Message(id, ProxyFetchWireCodec.ToWire(frame)), requester, null, null);
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Tunnel {frame.CorrelationId} frame ({id}) not sent: {exception.Message}");
+ }
+
+ return Task.CompletedTask;
+ };
+
+ ProxyTunnelOpenOutcome outcome;
+ try
+ {
+ outcome = await tunnels.OpenAsync(payload, toClient);
+ }
+ catch (Exception exception)
+ {
+ // OpenAsync is written not to throw; anything escaping still answers as a
+ // failure rather than leaving the browser waiting for a 101 that never comes.
+ outcome = new ProxyTunnelOpenOutcome
+ {
+ Success = false,
+ ErrorCode = ProxyFetchErrorCodes.Error,
+ ErrorMessage = exception.Message,
+ };
+ }
+
+ TransmitTunnelOpenAck(communication, requester, payload.CorrelationId, outcome);
+ });
+
+ return null;
+ }
+
+ private object OnProxyTunnelDataFrame(Message message, FQID destination, FQID source)
+ {
+ var tunnels = _tunnels;
+ if (tunnels != null)
+ {
+ tunnels.OnClientData(ProxyFetchWireCodec.TunnelFromWire(message?.Data));
+ }
+
+ return null;
+ }
+
+ private object OnProxyTunnelCloseFrame(Message message, FQID destination, FQID source)
+ {
+ var tunnels = _tunnels;
+ if (tunnels != null)
+ {
+ tunnels.OnClientClose(ProxyFetchWireCodec.TunnelFromWire(message?.Data));
+ }
+
+ return null;
+ }
+
+ private static void TransmitTunnelOpenAck(MessageCommunication communication, FQID requester, string tunnelId, ProxyTunnelOpenOutcome outcome)
+ {
+ if (communication == null || requester == null)
+ {
+ return;
+ }
+
+ try
+ {
+ communication.TransmitMessage(
+ new Message(
+ ProxyMessageIds.TunnelOpenResponseId,
+ ProxyFetchWireCodec.ToWire(new ProxyTunnelPayload
+ {
+ CorrelationId = tunnelId,
+ Success = outcome.Success,
+ StatusCode = outcome.StatusCode,
+ DataBase64 = outcome.HandshakeBytes == null || outcome.HandshakeBytes.Length == 0
+ ? null
+ : Convert.ToBase64String(outcome.HandshakeBytes),
+ ErrorCode = outcome.ErrorCode,
+ ErrorMessage = outcome.ErrorMessage,
+ })),
+ requester,
+ null,
+ null);
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(
+ true,
+ nameof(ProxyFetchBackgroundPlugin),
+ $"Tunnel open ack {tunnelId} not sent: {exception.Message}");
+ }
+ }
+
///
/// The endpoint to answer a request on. The explicit source FQID (rarely sent) wins; the
/// platform-filled ExternalMessageSourceEndPoint is the normal answer path. When neither
diff --git a/src/WebContent/ProxyFetchClient.cs b/src/WebContent/ProxyFetchClient.cs
index c88932c..689efde 100644
--- a/src/WebContent/ProxyFetchClient.cs
+++ b/src/WebContent/ProxyFetchClient.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using VideoOS.Platform;
using VideoOS.Platform.Messaging;
@@ -29,8 +31,14 @@ internal sealed class ProxyFetchClient
private object _responseFilterRegistration;
private object _leafResponseFilterRegistration;
private object _echoFilterRegistration;
+ private object _tunnelOpenResponseFilterRegistration;
+ private object _tunnelDataFrameFilterRegistration;
+ private object _tunnelCloseFrameFilterRegistration;
private ProxyFetchCorrelation _correlation;
private ProxyLeafCorrelation _leafCorrelation;
+ private ProxyTunnelCorrelation _tunnels;
+ private Timer _tunnelReaper;
+ private readonly ProxyTunnelLimits _tunnelLimits = ProxyTunnelLimits.Default();
private bool _trace;
internal bool IsReady => _communication != null;
@@ -74,6 +82,22 @@ internal void Setup(ServerId masterSite)
OnProxyLeafResponse,
new CommunicationIdFilter(ProxyMessageIds.LeafResponseId));
+ // #57: websocket tunnels. The open ack and every target-pushed frame arrive on
+ // these ids; the correlator owns the whole lifetime model and a sweep reclaims
+ // idle/half-open tunnels (ticket criterion 4).
+ _tunnels = new ProxyTunnelCorrelation();
+ _tunnelOpenResponseFilterRegistration = _communication.RegisterCommunicationFilter(
+ OnTunnelOpenResponse,
+ new CommunicationIdFilter(ProxyMessageIds.TunnelOpenResponseId));
+ _tunnelDataFrameFilterRegistration = _communication.RegisterCommunicationFilter(
+ OnTunnelDataFromTarget,
+ new CommunicationIdFilter(ProxyMessageIds.TunnelDataFromTargetId));
+ _tunnelCloseFrameFilterRegistration = _communication.RegisterCommunicationFilter(
+ OnTunnelCloseFromTarget,
+ new CommunicationIdFilter(ProxyMessageIds.TunnelCloseFromTargetId));
+ var sweepPeriod = TimeSpan.FromMilliseconds(Math.Max(1000, Math.Min(30_000, _tunnelLimits.IdleTimeoutMs / 2)));
+ _tunnelReaper = new Timer(_ => SweepTunnels(), null, sweepPeriod, sweepPeriod);
+
// Diagnostic (trace only): our own requests are filter-routed back to the sender's
// endpoint queue. Inspecting that echo tests whether THIS process deserializes our
// payload types at all: an intact echo isolates payload stripping to the response
@@ -100,7 +124,7 @@ internal void Close()
var communication = _communication;
if (communication != null)
{
- foreach (var registration in new[] { _responseFilterRegistration, _leafResponseFilterRegistration, _echoFilterRegistration })
+ foreach (var registration in new[] { _responseFilterRegistration, _leafResponseFilterRegistration, _echoFilterRegistration, _tunnelOpenResponseFilterRegistration, _tunnelDataFrameFilterRegistration, _tunnelCloseFrameFilterRegistration })
{
if (registration == null)
{
@@ -121,6 +145,26 @@ internal void Close()
_responseFilterRegistration = null;
_leafResponseFilterRegistration = null;
_echoFilterRegistration = null;
+ _tunnelOpenResponseFilterRegistration = null;
+ _tunnelDataFrameFilterRegistration = null;
+ _tunnelCloseFrameFilterRegistration = null;
+
+ // #57: every live tunnel ends with an explicit close so the Event Server releases
+ // its target sockets now, not at the ES-side idle sweep; then the sweep stops.
+ var reaper = _tunnelReaper;
+ _tunnelReaper = null;
+ reaper?.Dispose();
+
+ var tunnels = _tunnels;
+ _tunnels = null;
+ if (tunnels != null)
+ {
+ foreach (var tunnelId in tunnels.CloseAll("the Smart Client proxy client shut down"))
+ {
+ TryTransmitTunnelClose(tunnelId, "the Smart Client closed");
+ }
+ }
+
_communication = null;
_correlation = null;
_leafCorrelation = null;
@@ -250,6 +294,310 @@ internal Task RequestLeafAsync(string host, int port,
return completion;
}
+ ///
+ /// Ticket #57: open one websocket tunnel for a decided upgrade request. The Event Server
+ /// connects to the target, replays the handshake, and answers with its verbatim 101; the
+ /// returned handle then pumps raw tunnel bytes in both directions through messaging.
+ /// Never throws — every failure is an outcome the forwarder renders as 502.
+ ///
+ internal async Task OpenTunnelAsync(ProxyForwardedRequest request)
+ {
+ var communication = _communication;
+ var tunnels = _tunnels;
+ if (communication == null || tunnels == null)
+ {
+ return TunnelFailure(ProxyFetchErrorCodes.Error, "The proxy channel is not connected to the Event Server.");
+ }
+
+ var target = request == null ? null : request.Uri;
+ if (target == null)
+ {
+ return TunnelFailure(ProxyFetchErrorCodes.BadRequest, "The websocket tunnel request carried no target uri.");
+ }
+
+ if (tunnels.Count >= _tunnelLimits.ClientMaxTunnels)
+ {
+ return TunnelFailure(
+ ProxyFetchErrorCodes.Error,
+ $"This Smart Client already has {_tunnelLimits.ClientMaxTunnels} websocket tunnels open; close a proxied page and retry.");
+ }
+
+ var tunnelId = Guid.NewGuid().ToString("N");
+ tunnels.TryRegister(tunnelId);
+
+ try
+ {
+ TransmitTunnel(ProxyMessageIds.TunnelOpenRequestId, new ProxyTunnelPayload
+ {
+ CorrelationId = tunnelId,
+ Url = target.AbsoluteUri,
+ Headers = ProxyWebSocketHandshake.ToTunnelWireHeaders(request),
+ });
+ }
+ catch (Exception exception)
+ {
+ tunnels.EndLocal(tunnelId);
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel open not sent: {exception.Message}");
+ return TunnelFailure(ProxyFetchErrorCodes.Error, "The tunnel open request could not be sent to the Event Server.");
+ }
+
+ var openTask = tunnels.AwaitOpenAsync(tunnelId);
+ var winner = await Task.WhenAny(openTask, Task.Delay(_tunnelLimits.ClientOpenDeadlineMs));
+ if (winner != openTask)
+ {
+ // Half-open reclaim (ticket criterion 4): the Event Server may still be dialing the
+ // target, so release it too rather than leaving a socket to the idle sweep.
+ if (tunnels.EndLocal(tunnelId))
+ {
+ TryTransmitTunnelClose(tunnelId, "the Smart Client stopped waiting for the websocket handshake");
+ }
+
+ return TunnelFailure(
+ ProxyFetchErrorCodes.Timeout,
+ $"The Event Server did not answer the websocket tunnel open within {_tunnelLimits.ClientOpenDeadlineMs / 1000} seconds.");
+ }
+
+ var outcome = openTask.Result;
+ if (outcome == null)
+ {
+ return TunnelFailure(ProxyFetchErrorCodes.Error, "The websocket tunnel closed before it opened.");
+ }
+
+ if (outcome.Success)
+ {
+ outcome.Handle = new MessagingTunnelHandle(this, tunnelId);
+ }
+
+ return outcome;
+ }
+
+ /// Relay browser bytes toward the target; unknown/closed tunnels drop quietly.
+ private void SendTunnelData(string tunnelId, byte[] bytes)
+ {
+ var tunnels = _tunnels;
+ if (tunnels == null || tunnels.IsClosed(tunnelId))
+ {
+ return;
+ }
+
+ tunnels.Touch(tunnelId);
+ var frame = new ProxyTunnelPayload
+ {
+ CorrelationId = tunnelId,
+ Kind = ProxyTunnelFrameKinds.Data,
+ DataBase64 = Convert.ToBase64String(bytes),
+ };
+
+ try
+ {
+ TransmitTunnel(ProxyMessageIds.TunnelDataToTargetId, frame);
+ }
+ catch (Exception exception)
+ {
+ // Messaging is dying: end the tunnel so both pumps unwind instead of spin-failing.
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel data not sent ({tunnelId}): {exception.Message}");
+ tunnels.OnTargetClose(tunnelId, "the proxy messaging channel failed");
+ }
+ }
+
+ /// The browser side ended the tunnel: drop the session and tell the Event Server.
+ private void CloseTunnel(string tunnelId, string reason)
+ {
+ var tunnels = _tunnels;
+ if (tunnels == null)
+ {
+ return;
+ }
+
+ if (tunnels.EndLocal(tunnelId))
+ {
+ TryTransmitTunnelClose(tunnelId, reason);
+ }
+ }
+
+ private void TryTransmitTunnelClose(string tunnelId, string reason)
+ {
+ try
+ {
+ TransmitTunnel(ProxyMessageIds.TunnelCloseToTargetId, new ProxyTunnelPayload
+ {
+ CorrelationId = tunnelId,
+ Kind = ProxyTunnelFrameKinds.Close,
+ ErrorMessage = reason,
+ });
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel close not sent ({tunnelId}): {exception.Message}");
+ }
+ }
+
+ private static void TransmitTunnelAt(MessageCommunication communication, string messageId, ProxyTunnelPayload payload)
+ {
+ // destinationEndPoint null routes to the Event Server fetcher plugin, exactly like the
+ // fetch requests; the messaging layer stamps the sender endpoint for the unicast answer.
+ communication.TransmitMessage(new Message(messageId, ProxyFetchWireCodec.ToWire(payload)), null, null, null);
+ }
+
+ private void TransmitTunnel(string messageId, ProxyTunnelPayload payload)
+ {
+ var communication = _communication;
+ if (communication == null)
+ {
+ return;
+ }
+
+ TransmitTunnelAt(communication, messageId, payload);
+ }
+
+ private void SweepTunnels()
+ {
+ var tunnels = _tunnels;
+ if (tunnels == null)
+ {
+ return;
+ }
+
+ try
+ {
+ var reclaimed = tunnels.ReclaimIdle(TimeSpan.FromMilliseconds(_tunnelLimits.IdleTimeoutMs));
+ foreach (var tunnelId in reclaimed)
+ {
+ TryTransmitTunnelClose(tunnelId, "the Smart Client reclaimed an idle websocket tunnel");
+ }
+ }
+ catch (Exception exception)
+ {
+ EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel sweep faulted: {exception.Message}");
+ }
+ }
+
+ private object OnTunnelOpenResponse(Message message, FQID destination, FQID source)
+ {
+ var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data);
+ var tunnels = _tunnels;
+ if (payload == null || tunnels == null)
+ {
+ if (_trace && message != null)
+ {
+ EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Proxy tunnel open response not recognized.");
+ }
+
+ return null;
+ }
+
+ tunnels.CompleteOpen(payload.CorrelationId, new ProxyTunnelOpenOutcome
+ {
+ Success = payload.Success,
+ StatusCode = payload.StatusCode,
+ HandshakeBytes = DecodeTunnelBytes(payload.DataBase64),
+ ErrorCode = payload.ErrorCode,
+ ErrorMessage = payload.ErrorMessage,
+ });
+ return null;
+ }
+
+ private object OnTunnelDataFromTarget(Message message, FQID destination, FQID source)
+ {
+ var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data);
+ var tunnels = _tunnels;
+ if (payload == null || tunnels == null)
+ {
+ return null;
+ }
+
+ var bytes = DecodeTunnelBytes(payload.DataBase64);
+ if (bytes != null)
+ {
+ tunnels.OnTargetData(payload.CorrelationId, bytes);
+ }
+ else if (!string.IsNullOrEmpty(payload.DataBase64))
+ {
+ tunnels.OnTargetClose(payload.CorrelationId, "the tunnel data frame was malformed");
+ }
+
+ return null;
+ }
+
+ private object OnTunnelCloseFromTarget(Message message, FQID destination, FQID source)
+ {
+ var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data);
+ var tunnels = _tunnels;
+ if (payload != null && tunnels != null)
+ {
+ tunnels.OnTargetClose(payload.CorrelationId, payload.ErrorMessage ?? "the target closed the websocket");
+ }
+
+ return null;
+ }
+
+ private static byte[] DecodeTunnelBytes(string base64)
+ {
+ if (string.IsNullOrEmpty(base64))
+ {
+ return null;
+ }
+
+ try
+ {
+ return Convert.FromBase64String(base64);
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// The browser-connection seam over the messaging channel: reads drain the correlator's
+ /// ordered queue, writes relay base64-wrapped raw bytes, and Close ends the session once
+ /// and notifies the Event Server. The 16KB pump chunks keep every message inside the
+ /// classic messaging size budget.
+ ///
+ private sealed class MessagingTunnelHandle : ProxyTunnelHandle
+ {
+ private readonly ProxyFetchClient _client;
+ private int _closeSent;
+
+ internal MessagingTunnelHandle(ProxyFetchClient client, string tunnelId)
+ {
+ _client = client;
+ TunnelId = tunnelId;
+ }
+
+ internal override bool IsClosed => _client._tunnels == null || _client._tunnels.IsClosed(TunnelId);
+
+ internal override Task SendAsync(byte[] bytes)
+ {
+ _client.SendTunnelData(TunnelId, bytes);
+ return Task.CompletedTask;
+ }
+
+ internal override Task NextDataAsync()
+ {
+ var tunnels = _client._tunnels;
+ return tunnels == null ? Task.FromResult(null) : tunnels.NextDataAsync(TunnelId);
+ }
+
+ internal override void Close(string reason)
+ {
+ if (Interlocked.Exchange(ref _closeSent, 1) == 0)
+ {
+ _client.CloseTunnel(TunnelId, reason);
+ }
+ }
+ }
+
+ private static ProxyTunnelOpenOutcome TunnelFailure(string errorCode, string message)
+ {
+ return new ProxyTunnelOpenOutcome
+ {
+ Success = false,
+ ErrorCode = errorCode,
+ ErrorMessage = message,
+ };
+ }
+
private object OnProxyLeafResponse(Message message, FQID destination, FQID source)
{
var response = ProxyFetchWireCodec.LeafResponseFromWire(message?.Data);
diff --git a/src/WebContent/ProxyForwardCodec.cs b/src/WebContent/ProxyForwardCodec.cs
index d15d62d..e1ea95a 100644
--- a/src/WebContent/ProxyForwardCodec.cs
+++ b/src/WebContent/ProxyForwardCodec.cs
@@ -109,6 +109,20 @@ internal static bool TryParseRequestHead(string head, Uri tunnelOrigin, out Prox
rejectReason = $"The proxy request target '{requestLine[1]}' was not an absolute uri.";
return false;
}
+ else if (uri.Scheme == "ws" || uri.Scheme == "wss")
+ {
+ // RFC 6455 5.1: a websocket upgrade reaches an HTTP proxy with the ABSOLUTE ws://
+ // or wss:// target, not the http one. The scheme letter carries no gating meaning
+ // here (ws is to http what wss is to https), so rebase the uri onto its transport
+ // twin: the origin allowlist, the decision table, and the tunnel handshake all
+ // speak http origins, and RawTarget keeps exactly what the wire carried.
+ var transportScheme = uri.Scheme == "ws" ? Uri.UriSchemeHttp : Uri.UriSchemeHttps;
+ if (!Uri.TryCreate(new UriBuilder(uri) { Scheme = transportScheme }.ToString(), UriKind.Absolute, out uri))
+ {
+ rejectReason = $"The proxy websocket target '{requestLine[1]}' was not a usable uri.";
+ return false;
+ }
+ }
if (uri != null && !ProxyPolicy.IsHttpUri(uri))
{
diff --git a/src/WebContent/ProxyTunnelCorrelation.cs b/src/WebContent/ProxyTunnelCorrelation.cs
new file mode 100644
index 0000000..b2effc2
--- /dev/null
+++ b/src/WebContent/ProxyTunnelCorrelation.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace WebContent
+{
+ ///
+ /// Ticket #57: the client-side registry of live websocket tunnels. The messaging receive
+ /// thread feeds it (open acks, target data, target closes); the browser connection pumps
+ /// drain it (ordered data, close sentinels). It owns no transport - ProxyFetchClient turns
+ /// these events into MIP messages - so the whole lifetime model (half-open deadlines, idle
+ /// reclaim, ordered pumping, orphan-ack cleanup) is unit-testable on its own.
+ ///
+ internal sealed class ProxyTunnelCorrelation
+ {
+ private sealed class Session
+ {
+ internal readonly string Id;
+ internal DateTime LastActivityUtc;
+ internal TaskCompletionSource OpenWaiter;
+ internal readonly Queue Data = new Queue