From e65487ae3f1585413b86938e789a8a8f0ceacc14 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 27 Oct 2026 10:24:01 +0100 Subject: [PATCH 1/3] Make DevToolsPlugin usable from containers and secure its WebSocket. Closes #2005 - Add `port` setting to listen on a fixed port (0, default, picks a random port) - Add `launchBrowser` setting; when false, log a devtools:// URL to open manually - Bind the DevTools WebSocket to the proxy's IP address (0.0.0.0 -> all interfaces) and start it after command-line options are applied so --ip-address is honored - Require a random per-run token in the WebSocket path and only accept connections from DevTools frontend origins - Update the v4.0.0 schema and skill docs; add tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DevToolsWebSocketServerTests.cs | 100 +++++++++++++++ DevProxy.Plugins/Inspection/DevToolsPlugin.cs | 117 +++++++++++++++--- .../Inspection/WebSocketServer.cs | 78 ++++++++++-- schemas/v4.0.0/devtoolsplugin.schema.json | 10 ++ skills/dev-proxy/references/configuration.md | 2 + skills/dev-proxy/references/plugin-catalog.md | 2 + 6 files changed, 286 insertions(+), 23 deletions(-) create mode 100644 DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs diff --git a/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs b/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs new file mode 100644 index 000000000..aefb3ec3e --- /dev/null +++ b/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Net.WebSockets; +using DevProxy.Plugins.Inspection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DevProxy.Integration.Tests; + +/// +/// Covers the DevToolsPlugin WebSocket server's access checks (path token +/// and Origin) and how it maps the proxy IP address to the listener host. +/// +public sealed class DevToolsWebSocketServerTests +{ + private const string Token = "4f2b8f0e-7c1d-4b8e-9d3a-2a6f5e1c0b9d"; + + [Theory] + [InlineData("devtools://devtools")] + [InlineData("http://localhost:9222")] + [InlineData("http://127.0.0.1:9222")] + public async Task AcceptsConnection_WithTokenAndDevToolsOrigin(string origin) + { + using var server = StartServer(out var port); + + using var client = await ConnectAsync(port, Token, origin); + + Assert.Equal(WebSocketState.Open, client.State); + } + + [Theory] + [InlineData("")] + [InlineData("wrong")] + [InlineData(Token + "/extra")] + public async Task RejectsConnection_WithInvalidToken(string path) + { + using var server = StartServer(out var port); + + _ = await Assert.ThrowsAsync(() => ConnectAsync(port, path, "devtools://devtools")); + } + + [Theory] + [InlineData("https://example.com")] + [InlineData("http://localhost:3000")] + [InlineData("null")] + [InlineData(null)] + public async Task RejectsConnection_FromOtherOrigins(string? origin) + { + using var server = StartServer(out var port); + + _ = await Assert.ThrowsAsync(() => ConnectAsync(port, Token, origin)); + } + + [Theory] + [InlineData(null, "127.0.0.1", "127.0.0.1")] + [InlineData("", "127.0.0.1", "127.0.0.1")] + [InlineData("127.0.0.1", "127.0.0.1", "127.0.0.1")] + [InlineData("0.0.0.0", "*", "127.0.0.1")] + [InlineData("::", "*", "127.0.0.1")] + [InlineData("192.168.1.10", "192.168.1.10", "192.168.1.10")] + [InlineData("::1", "[::1]", "[::1]")] + public void GetWebSocketHosts_MapsProxyIPAddress(string? ipAddress, string expectedListenerHost, string expectedConnectHost) + { + var (listenerHost, connectHost) = DevToolsPlugin.GetWebSocketHosts(ipAddress); + + Assert.Equal(expectedListenerHost, listenerHost); + Assert.Equal(expectedConnectHost, connectHost); + } + + private static WebSocketServer StartServer(out int port) + { + port = NetUtil.GetFreePort(); + var server = new WebSocketServer("127.0.0.1", port, Token, NullLogger.Instance); + server.Start(); + return server; + } + + private static async Task ConnectAsync(int port, string path, string? origin) + { + var client = new ClientWebSocket(); + if (origin is not null) + { + client.Options.SetRequestHeader("Origin", origin); + } + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await client.ConnectAsync(new Uri($"ws://127.0.0.1:{port}/{path}"), cts.Token); + return client; + } + catch + { + client.Dispose(); + throw; + } + } +} diff --git a/DevProxy.Plugins/Inspection/DevToolsPlugin.cs b/DevProxy.Plugins/Inspection/DevToolsPlugin.cs index bee76fbe2..bf43273c9 100644 --- a/DevProxy.Plugins/Inspection/DevToolsPlugin.cs +++ b/DevProxy.Plugins/Inspection/DevToolsPlugin.cs @@ -40,6 +40,21 @@ public sealed class DevToolsPluginConfiguration /// Use this value when you install the browser in a non-standard /// path. public string PreferredBrowserPath { get; set; } = string.Empty; + + /// + /// Port on which the plugin listens for DevTools connections. 0 (default) + /// picks a random available port. + /// + /// Use a fixed port when you need to publish it, eg. when running + /// Dev Proxy in a container. + public int Port { get; set; } + + /// + /// Whether to launch the browser with DevTools. When false, the plugin + /// only logs the DevTools URL, which you can open in Microsoft Edge or + /// Google Chrome yourself. + /// + public bool LaunchBrowser { get; set; } = true; } public sealed class DevToolsPlugin( @@ -74,6 +89,7 @@ public sealed class DevToolsPlugin( private readonly Lock _replayLock = new(); private CancellationToken? _cancellationToken; + private bool _isProxyCommand; private WebSocketServer? _webSocket; public override string Name => nameof(DevToolsPlugin); @@ -91,7 +107,19 @@ public override async Task InitializeAsync(InitArgs e, CancellationToken cancell return; } - InitInspector(); + _isProxyCommand = true; + } + + public override void OptionsLoaded(OptionsLoadedArgs e) + { + base.OptionsLoaded(e); + + // start the inspector after command-line options have been applied + // so that it binds to the IP address specified using --ip-address + if (_isProxyCommand) + { + InitInspector(); + } } public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) @@ -886,30 +914,62 @@ private void InitInspector() { var browserPath = string.Empty; - try + if (Configuration.LaunchBrowser) { - browserPath = GetBrowserPath(); + try + { + browserPath = GetBrowserPath(); + } + catch (NotSupportedException ex) + { + Logger.LogError(ex, "Error starting {Plugin}. Error finding the browser.", Name); + return; + } + + // check if the browser is installed + if (!File.Exists(browserPath)) + { + Logger.LogError("Error starting {Plugin}. Browser executable not found at {BrowserPath}", Name, browserPath); + return; + } } - catch (NotSupportedException ex) + + if (Configuration.Port is < 0 or > IPEndPoint.MaxPort) { - Logger.LogError(ex, "Error starting {Plugin}. Error finding the browser.", Name); + Logger.LogError("Error starting {Plugin}. {Port} must be between 0 and {MaxPort} but was {Value}", Name, nameof(Configuration.Port), IPEndPoint.MaxPort, Configuration.Port); return; } - // check if the browser is installed - if (!File.Exists(browserPath)) + var port = Configuration.Port == 0 ? GetFreePort() : Configuration.Port; + // random token required in the WebSocket path so that only clients + // that got the URL from Dev Proxy can connect + var token = Guid.NewGuid().ToString(); + var (listenerHost, connectHost) = GetWebSocketHosts(ProxyConfiguration.IPAddress); + + _webSocket = new(listenerHost, port, token, Logger); + _webSocket.MessageReceived += SocketMessageReceived; + _webSocket.ClientConnected += OnWebSocketClientConnected; + try { - Logger.LogError("Error starting {Plugin}. Browser executable not found at {BrowserPath}", Name, browserPath); + _webSocket.Start(); + } + catch (Exception ex) when (ex is HttpListenerException or SocketException) + { + Logger.LogError(ex, "Error starting {Plugin}. Couldn't listen for DevTools connections on {Host}:{Port}", Name, listenerHost, port); + _webSocket.Dispose(); + _webSocket = null; return; } - var port = GetFreePort(); - _webSocket = new(port, Logger); - _webSocket.MessageReceived += SocketMessageReceived; - _webSocket.ClientConnected += OnWebSocketClientConnected; - _ = _webSocket.StartAsync(); + var webSocketAddress = $"{connectHost}:{port}/{token}"; - var inspectionUrl = $"http://localhost:9222/devtools/inspector.html?ws=127.0.0.1:{port}"; + if (!Configuration.LaunchBrowser) + { + Logger.LogInformation("{Name} available at devtools://devtools/bundled/inspector.html?ws={WebSocketAddress}. Open this URL in Microsoft Edge or Google Chrome to inspect requests.", Name, webSocketAddress); + return; + } + + var inspectionUrl = $"http://localhost:9222/devtools/inspector.html?ws={webSocketAddress}"; var profilePath = Path.Combine(Path.GetTempPath(), "devtools-devproxy"); var args = $"{inspectionUrl} --remote-debugging-port=9222 --user-data-dir=\"{profilePath}\""; @@ -1047,6 +1107,35 @@ private static int GetFreePort() return port; } + /// + /// Gets the host to listen on for DevTools connections, matching the + /// proxy's IP address, and the host DevTools should connect to. + /// + internal static (string ListenerHost, string ConnectHost) GetWebSocketHosts(string? ipAddress) + { + if (string.IsNullOrWhiteSpace(ipAddress)) + { + return ("127.0.0.1", "127.0.0.1"); + } + + if (!IPAddress.TryParse(ipAddress, out var address)) + { + return (ipAddress, ipAddress); + } + + if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) + { + // listen on all interfaces; connect through loopback, which is + // where published container ports and forwarded ports end up + return ("*", "127.0.0.1"); + } + + var host = address.AddressFamily == AddressFamily.InterNetworkV6 + ? $"[{address}]" + : address.ToString(); + return (host, host); + } + private static string GetRequestId(IProxySession? session) { if (session is null) diff --git a/DevProxy.Plugins/Inspection/WebSocketServer.cs b/DevProxy.Plugins/Inspection/WebSocketServer.cs index 3dd4f47e8..8ed54184a 100644 --- a/DevProxy.Plugins/Inspection/WebSocketServer.cs +++ b/DevProxy.Plugins/Inspection/WebSocketServer.cs @@ -12,10 +12,25 @@ namespace DevProxy.Plugins.Inspection; -public sealed class WebSocketServer(int port, ILogger logger) : IDisposable +public sealed class WebSocketServer(string host, int port, string token, ILogger logger) : IDisposable { + // Origins used by the Chromium DevTools frontend: the bundled frontend + // (devtools://devtools/...) and the frontend served by the browser's + // remote debugging server, which the plugin launches on port 9222. + // Browsers always send Origin on WebSocket handshakes and don't apply + // CORS to them, so checking it prevents arbitrary web pages from + // connecting and reading intercepted traffic. + internal static readonly string[] AllowedOrigins = + [ + "devtools://devtools", + "http://localhost:9222", + "http://127.0.0.1:9222" + ]; + private readonly ILogger _logger = logger; + private readonly string _host = host; private readonly int _port = port; + private readonly string _path = $"/{token}"; static readonly SemaphoreSlim _webSocketSemaphore = new(1, 1); private HttpListener? _listener; @@ -28,27 +43,72 @@ public sealed class WebSocketServer(int port, ILogger logger) : IDisposable public event Action? ClientConnected; #pragma warning restore CA1003 - public async Task StartAsync() + /// + /// Starts listening for connections. Throws if the listener can't be + /// started, eg. because the port is already in use. + /// + public void Start() { _listener = new(); - _listener.Prefixes.Add($"http://127.0.0.1:{_port}/"); + _listener.Prefixes.Add($"http://{_host}:{_port}/"); _listener.Start(); - while (true) + _ = AcceptConnectionsAsync(_listener); + } + + internal static bool IsAllowedOrigin(string? origin) => + origin is not null && + AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase); + + private async Task AcceptConnectionsAsync(HttpListener listener) + { + while (listener.IsListening) { - var context = await _listener.GetContextAsync(); + HttpListenerContext context; + try + { + context = await listener.GetContextAsync(); + } + catch (Exception ex) when (ex is HttpListenerException or ObjectDisposedException or InvalidOperationException) + { + // listener has been stopped + return; + } + + if (!context.Request.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + context.Response.Close(); + continue; + } + + if (!string.Equals(context.Request.Url?.AbsolutePath, _path, StringComparison.Ordinal)) + { + _logger.LogWarning("[WS] Rejected DevTools connection with an invalid token"); + context.Response.StatusCode = 403; + context.Response.Close(); + continue; + } - if (context.Request.IsWebSocketRequest) + var origin = context.Request.Headers["Origin"]; + if (!IsAllowedOrigin(origin)) + { + _logger.LogWarning("[WS] Rejected DevTools connection from origin {Origin}", origin ?? "(none)"); + context.Response.StatusCode = 403; + context.Response.Close(); + continue; + } + + try { var webSocketContext = await context.AcceptWebSocketAsync(null); _webSocket = webSocketContext.WebSocket; ClientConnected?.Invoke(); _ = HandleMessagesAsync(_webSocket); } - else + catch (WebSocketException ex) { - context.Response.StatusCode = 400; - context.Response.Close(); + _logger.LogError(ex, "[WS] Error accepting DevTools connection"); } } } diff --git a/schemas/v4.0.0/devtoolsplugin.schema.json b/schemas/v4.0.0/devtoolsplugin.schema.json index 840fdf917..4bbdfec64 100644 --- a/schemas/v4.0.0/devtoolsplugin.schema.json +++ b/schemas/v4.0.0/devtoolsplugin.schema.json @@ -20,6 +20,16 @@ "preferredBrowserPath": { "type": "string", "description": "Path to the browser executable. When specified, overrides preferredBrowser." + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Port on which the plugin listens for DevTools connections. Use a fixed port when you need to publish it, eg. when running Dev Proxy in a container. Default: 0 (random available port)." + }, + "launchBrowser": { + "type": "boolean", + "description": "Whether to launch the browser with DevTools. When false, the plugin logs the DevTools URL that you can open in Microsoft Edge or Google Chrome yourself, eg. when running Dev Proxy in a container. Default: true." } }, "additionalProperties": false diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md index 4eea14fd6..0f0cecbd8 100644 --- a/skills/dev-proxy/references/configuration.md +++ b/skills/dev-proxy/references/configuration.md @@ -324,3 +324,5 @@ Exposes Dev Proxy activity in Chrome DevTools (HTTP and STDIO). |----------|---------|-------------| | `preferredBrowser` | `Edge` | `Edge`, `EdgeDev`, `EdgeBeta`, or `Chrome` | | `preferredBrowserPath` | empty | Path to the browser executable; overrides `preferredBrowser` | +| `port` | `0` | Port for DevTools connections; `0` picks a random port. Set a fixed port to publish it from a container | +| `launchBrowser` | `true` | When `false`, logs the DevTools URL to open in Edge or Chrome instead of launching the browser | diff --git a/skills/dev-proxy/references/plugin-catalog.md b/skills/dev-proxy/references/plugin-catalog.md index 412a46806..7077f51c2 100644 --- a/skills/dev-proxy/references/plugin-catalog.md +++ b/skills/dev-proxy/references/plugin-catalog.md @@ -139,6 +139,8 @@ Exposes Dev Proxy activity in Chrome DevTools (HTTP and STDIO). |----------|---------|-------------| | `preferredBrowser` | `Edge` | `Edge`, `EdgeDev`, `EdgeBeta`, or `Chrome` | | `preferredBrowserPath` | empty | Path to the browser executable; overrides `preferredBrowser` | +| `port` | `0` | Port for DevTools connections; `0` picks a random port. Set a fixed port to publish it from a container | +| `launchBrowser` | `true` | When `false`, logs the DevTools URL to open in Edge or Chrome instead of launching the browser | ### MockStdioResponsePlugin From ceb40c166186bd354275158de9d3e921e4490762 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 27 Oct 2026 10:39:29 +0100 Subject: [PATCH 2/3] Escape control characters in logged DevTools Origin header Addresses CodeQL log-injection alert: the Origin header is client-controlled and the listener may be network-reachable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DevToolsWebSocketServerTests.cs | 9 +++++++++ DevProxy.Plugins/Inspection/WebSocketServer.cs | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs b/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs index aefb3ec3e..74443d387 100644 --- a/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs +++ b/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs @@ -69,6 +69,15 @@ public void GetWebSocketHosts_MapsProxyIPAddress(string? ipAddress, string expec Assert.Equal(expectedConnectHost, connectHost); } + [Theory] + [InlineData(null, "(none)")] + [InlineData("https://example.com", "https://example.com")] + [InlineData("https://evil\r\n[WS] forged", "https://evil\\u000d\\u000a[WS] forged")] + public void SanitizeForLog_EscapesControlCharacters(string? value, string expected) + { + Assert.Equal(expected, WebSocketServer.SanitizeForLog(value)); + } + private static WebSocketServer StartServer(out int port) { port = NetUtil.GetFreePort(); diff --git a/DevProxy.Plugins/Inspection/WebSocketServer.cs b/DevProxy.Plugins/Inspection/WebSocketServer.cs index 8ed54184a..b83707363 100644 --- a/DevProxy.Plugins/Inspection/WebSocketServer.cs +++ b/DevProxy.Plugins/Inspection/WebSocketServer.cs @@ -60,6 +60,15 @@ internal static bool IsAllowedOrigin(string? origin) => origin is not null && AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase); + // Origin is client-controlled and the listener may be reachable from the + // network, so escape control characters to prevent log forging. + internal static string SanitizeForLog(string? value) => + value is null + ? "(none)" + : string.Concat(value.Select(c => char.IsControl(c) + ? $"\\u{(int)c:x4}" + : c.ToString())); + private async Task AcceptConnectionsAsync(HttpListener listener) { while (listener.IsListening) @@ -93,7 +102,7 @@ private async Task AcceptConnectionsAsync(HttpListener listener) var origin = context.Request.Headers["Origin"]; if (!IsAllowedOrigin(origin)) { - _logger.LogWarning("[WS] Rejected DevTools connection from origin {Origin}", origin ?? "(none)"); + _logger.LogWarning("[WS] Rejected DevTools connection from origin {Origin}", SanitizeForLog(origin)); context.Response.StatusCode = 403; context.Response.Close(); continue; From 9fc05acbbe96248f94a9b13e899ffe018dd22f9e Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 27 Oct 2026 10:41:45 +0100 Subject: [PATCH 3/3] Fall back to loopback when DevTools can't listen on all interfaces On Windows, HttpListener needs admin rights or a URL ACL for the * prefix, so with --ip-address 0.0.0.0 the DevTools plugin would otherwise fail where it previously worked on 127.0.0.1. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Plugins/Inspection/DevToolsPlugin.cs | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/DevProxy.Plugins/Inspection/DevToolsPlugin.cs b/DevProxy.Plugins/Inspection/DevToolsPlugin.cs index bf43273c9..fd2573ee6 100644 --- a/DevProxy.Plugins/Inspection/DevToolsPlugin.cs +++ b/DevProxy.Plugins/Inspection/DevToolsPlugin.cs @@ -946,18 +946,19 @@ private void InitInspector() var token = Guid.NewGuid().ToString(); var (listenerHost, connectHost) = GetWebSocketHosts(ProxyConfiguration.IPAddress); - _webSocket = new(listenerHost, port, token, Logger); - _webSocket.MessageReceived += SocketMessageReceived; - _webSocket.ClientConnected += OnWebSocketClientConnected; - try - { - _webSocket.Start(); + _webSocket = TryStartWebSocketServer(listenerHost, port, token, out var startException); + if (_webSocket is null && listenerHost == "*") + { + // on Windows, listening on all interfaces requires admin rights + // or a URL ACL; fall back to loopback so the plugin keeps working + Logger.LogWarning(startException, "{Plugin} couldn't listen for DevTools connections on all interfaces. Listening on 127.0.0.1 instead, so DevTools can only connect from this machine", Name); + listenerHost = "127.0.0.1"; + _webSocket = TryStartWebSocketServer(listenerHost, port, token, out startException); } - catch (Exception ex) when (ex is HttpListenerException or SocketException) + + if (_webSocket is null) { - Logger.LogError(ex, "Error starting {Plugin}. Couldn't listen for DevTools connections on {Host}:{Port}", Name, listenerHost, port); - _webSocket.Dispose(); - _webSocket = null; + Logger.LogError(startException, "Error starting {Plugin}. Couldn't listen for DevTools connections on {Host}:{Port}", Name, listenerHost, port); return; } @@ -1111,6 +1112,26 @@ private static int GetFreePort() /// Gets the host to listen on for DevTools connections, matching the /// proxy's IP address, and the host DevTools should connect to. /// + private WebSocketServer? TryStartWebSocketServer(string host, int port, string token, out Exception? exception) + { + var server = new WebSocketServer(host, port, token, Logger); + try + { + server.Start(); + } + catch (Exception ex) when (ex is HttpListenerException or SocketException) + { + server.Dispose(); + exception = ex; + return null; + } + + server.MessageReceived += SocketMessageReceived; + server.ClientConnected += OnWebSocketClientConnected; + exception = null; + return server; + } + internal static (string ListenerHost, string ConnectHost) GetWebSocketHosts(string? ipAddress) { if (string.IsNullOrWhiteSpace(ipAddress))