Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions DevProxy.Integration.Tests/DevToolsWebSocketServerTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Covers the DevToolsPlugin WebSocket server's access checks (path token
/// and Origin) and how it maps the proxy IP address to the listener host.
/// </summary>
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<WebSocketException>(() => 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<WebSocketException>(() => 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<ClientWebSocket> 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;
}
}
}
138 changes: 124 additions & 14 deletions DevProxy.Plugins/Inspection/DevToolsPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ public sealed class DevToolsPluginConfiguration
/// <remarks>Use this value when you install the browser in a non-standard
/// path.</remarks>
public string PreferredBrowserPath { get; set; } = string.Empty;

/// <summary>
/// Port on which the plugin listens for DevTools connections. 0 (default)
/// picks a random available port.
/// </summary>
/// <remarks>Use a fixed port when you need to publish it, eg. when running
/// Dev Proxy in a container.</remarks>
public int Port { get; set; }

/// <summary>
/// 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.
/// </summary>
public bool LaunchBrowser { get; set; } = true;
}

public sealed class DevToolsPlugin(
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand Down Expand Up @@ -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}\"";

Expand Down Expand Up @@ -1047,6 +1108,55 @@ private static int GetFreePort()
return port;
}

/// <summary>
/// Gets the host to listen on for DevTools connections, matching the
/// proxy's IP address, and the host DevTools should connect to.
/// </summary>
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)
Expand Down
Loading
Loading