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"/> + +