diff --git a/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs b/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs new file mode 100644 index 00000000..74443d38 --- /dev/null +++ b/DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs @@ -0,0 +1,109 @@ +// 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); + } + + [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(); + 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 bee76fbe..fd2573ee 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,63 @@ 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 = 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); + } + + if (_webSocket is null) { - Logger.LogError("Error starting {Plugin}. Browser executable not found at {BrowserPath}", Name, browserPath); + Logger.LogError(startException, "Error starting {Plugin}. Couldn't listen for DevTools connections on {Host}:{Port}", Name, listenerHost, port); 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 +1108,55 @@ 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. + /// + 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)) + { + 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 3dd4f47e..b8370736 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,81 @@ 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); + + // 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) { - 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) + 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; + } + + var origin = context.Request.Headers["Origin"]; + if (!IsAllowedOrigin(origin)) + { + _logger.LogWarning("[WS] Rejected DevTools connection from origin {Origin}", SanitizeForLog(origin)); + 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 840fdf91..4bbdfec6 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 4eea14fd..0f0cecbd 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 412a4680..7077f51c 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