/* * Copyright (c) 2026 Proton AG * * This file is part of ProtonVPN. * * ProtonVPN is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * ProtonVPN is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with ProtonVPN. If not, see . */ using System; using System.Diagnostics; using System.Linq; using System.Net.Http; using System.Net.WebSockets; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using FlaUI.Core.Tools; using NUnit.Framework; using ProtonVPN.Common.Core.Extensions; namespace ProtonVPN.UI.Tests.TestsHelper; public class BrowserUtils { private const int CHROME_PORT = 9222; private const int EDGE_PORT = 9223; private const string CHROME_PATH = @"C:\Program Files\Google\Chrome\Application\chrome.exe"; private const string EDGE_PATH = @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"; private const string WEB_RTC_SCRIPT = """ new Promise((resolve) => { const candidates = []; const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); pc.createDataChannel(''); pc.createOffer().then(o => pc.setLocalDescription(o)); pc.onicecandidate = e => { if (!e.candidate) { pc.close(); resolve(candidates); return; } candidates.push(e.candidate.candidate); }; setTimeout(() => { pc.close(); resolve(candidates); }, 5000); }) """; public static void KillAllBrowsers() { Thread.Sleep(TestConstants.OneSecondTimeout); foreach (string? name in new[] { "chrome", "msedge" }) { foreach (Process process in Process.GetProcessesByName(name)) { try { process.Kill(); process.WaitForExit(TestConstants.FiveSecondsTimeout); } catch { process.Kill(true); process.WaitForExit(TestConstants.FiveSecondsTimeout); } } } Thread.Sleep(TestConstants.FiveSecondsTimeout); } public static void VerifyWebRtcNotLeaking(string browserApp, string vpnIp) { string publicIp = GetBrowserWebRtcIpWithRetry(browserApp); Assert.That(publicIp, Is.EqualTo(vpnIp).Or.Contains("No internet"), $"WebRTC leak detected in {browserApp}!" + $"\nExposed IP: {publicIp}" + $"\nExpected VPN IP: {vpnIp}"); } public static void VerifyBrowserIpWithRetry(string browserApp, bool hasVpn, string? ipAddressToCompare) { string? browserIp = null; RetryResult retry = Retry.WhileEmpty( () => { browserIp = GetBrowserIpWithRetry(browserApp); return browserIp; }, TestConstants.ThirtySecondsTimeout, TestConstants.ApiRetryInterval); if (retry.Success) { Assert.That((browserIp == ipAddressToCompare) == hasVpn, $"Expected {browserApp} to have VPN {hasVpn.ToOnOffString()}" + $"\n{browserApp} has IP: {browserIp}" + $"\nVPN App has IP: {ipAddressToCompare}"); } } public static void AssertBrowserInternetAvailability(string browserApp, bool shouldBeAvailable) { string? browserIp = null; RetryResult retry = Retry.WhileEmpty( () => { browserIp = GetBrowserIpWithRetry(browserApp); return browserIp; }, TestConstants.ThirtySecondsTimeout, TestConstants.ApiRetryInterval); if (retry.Success) { if (shouldBeAvailable) { Assert.That(browserIp, Does.Match(@"\b\d{1,3}(\.\d{1,3}){3}\b"), "Expected internet to be available."); } else { Assert.That(browserIp, Does.Contain("No internet").Or.Contain("Your Internet access is blocked").Or.Contain("This site can’t be reached").Or.Contain("Press space to play"), "Expected internet to not be available."); } } } public static void AssertBrowserCanLoadDuckDuckGo(string browserApp) { AssertBrowserLoadsUrl(browserApp, "https://duckduckgo.com/", "DuckDuckGo"); } public static void OpenStreamingWebsite(string browserApp) { AssertBrowserLoadsUrl(browserApp, "https://abc.com/watch-live", "ABC Live Stream"); } private static void AssertBrowserLoadsUrl(string browserApp, string url, string expectedTitle) { RetryResult retry = Retry.WhileEmpty( () => GetBrowserPageTitleWithRetry(browserApp, url), TestConstants.OneMinuteTimeout, TestConstants.ApiRetryInterval); Assert.That(retry.Success, Is.True, $"{expectedTitle} did not load within timeout."); Assert.That(retry.Result, Does.Contain(expectedTitle), $"Expected {expectedTitle} page title, got: {retry.Result}"); } private static string GetBrowserIpWithRetry(string browserApp) { // This method connects to the Browser via CDP and gets the IP that the Browser sees // It uses https://api.ipify.org instead of http://ip-api.com/json, because the Browser forces HTTPS via HSTS, and ip-api.com does not support HTTPS on the free tier string url = "https://api.ipify.org"; return ExecuteWithBrowserRetry(browserApp, port => ExecuteScriptInBrowserAsync(port, url, "document.body.innerText.trim()")); } private static string GetBrowserWebRtcIpWithRetry(string browserApp) { return ExecuteWithBrowserRetry(browserApp, port => ExecuteWebRtcScriptInBrowserAsync(port)); } private static string GetBrowserPageTitleWithRetry(string browserApp, string url) { return ExecuteWithBrowserRetry(browserApp, port => ExecuteScriptInBrowserAsync(port, url, "document.title")); } private static string ExecuteWithBrowserRetry( string browserApp, Func> operation) { (string Path, int DebugPort) browserConfig = GetBrowserConfig(browserApp); RetryResult retry = Retry.WhileEmpty( () => { StartBrowserWithCDP(browserConfig.Path, browserConfig.DebugPort); return operation(browserConfig.DebugPort).Result ?? string.Empty; }, TestConstants.OneMinuteTimeout, TestConstants.ApiRetryInterval, ignoreException: true); return retry.Result ?? "No internet"; } private static (string Path, int DebugPort) GetBrowserConfig(string browserApp) { switch (browserApp) { case "Google Chrome": return (CHROME_PATH, CHROME_PORT); case "Edge": return (EDGE_PATH, EDGE_PORT); default: throw new ArgumentException($"Unknown browser: {browserApp}"); } } private static void StartBrowserWithCDP(string browserPath, int debugPort) { Process.Start(new ProcessStartInfo { FileName = browserPath, Arguments = $"--remote-debugging-port={debugPort} --headless about:blank" }); } private static async Task ExecuteScriptInBrowserAsync(int debugPort, string url, string expression) { await Task.Delay(TestConstants.TwoSecondsTimeout); using ClientWebSocket ws = await ConnectToBrowserAsync(debugPort); await NavigateToUrlAsync(ws, url); await Task.Delay(TestConstants.TwoSecondsTimeout); JsonElement evalResult = await EvaluateExpressionAsync(ws, expression, awaitPromise: false); return ExtractStringResult(evalResult); } private static async Task ExecuteWebRtcScriptInBrowserAsync(int debugPort) { await Task.Delay(TestConstants.TwoSecondsTimeout); using ClientWebSocket ws = await ConnectToBrowserAsync(debugPort); JsonElement evalResult = await EvaluateExpressionAsync(ws, WEB_RTC_SCRIPT, awaitPromise: true); return ExtractWebRtcIp(evalResult); } private static async Task ConnectToBrowserAsync(int debugPort) { using HttpClient http = new HttpClient(); string json = await http.GetStringAsync($"http://localhost:{debugPort}/json"); JsonElement tabs = JsonSerializer.Deserialize(json); string wsUrl = FindPageWebSocketUrl(tabs); ClientWebSocket ws = new ClientWebSocket(); await ws.ConnectAsync(new Uri(wsUrl), CancellationToken.None); return ws; } private static string FindPageWebSocketUrl(JsonElement tabs) { foreach (JsonElement tab in tabs.EnumerateArray()) { if (tab.GetProperty("type").GetString() == "page") { return tab.GetProperty("webSocketDebuggerUrl").GetString() ?? ""; } } throw new InvalidOperationException("No page tab found in browser"); } private static async Task SendCommandAsync(ClientWebSocket ws, object command) { string msg = JsonSerializer.Serialize(command); await ws.SendAsync( Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, CancellationToken.None); byte[] buffer = new byte[4096]; WebSocketReceiveResult result = await ws.ReceiveAsync(buffer, CancellationToken.None); return JsonSerializer.Deserialize(Encoding.UTF8.GetString(buffer, 0, result.Count)); } private static async Task NavigateToUrlAsync(ClientWebSocket ws, string url) { await SendCommandAsync(ws, new { id = 1, method = "Page.navigate", @params = new { url } }); } private static async Task EvaluateExpressionAsync(ClientWebSocket ws, string expression, bool awaitPromise) { return await SendCommandAsync(ws, new { id = 2, method = "Runtime.evaluate", @params = new { expression, awaitPromise, returnByValue = awaitPromise } }); } private static string ExtractStringResult(JsonElement evalResult) { return evalResult .GetProperty("result") .GetProperty("result") .GetProperty("value") .GetString() ?? "unknown"; } private static string ExtractWebRtcIp(JsonElement evalResult) { JsonElement rawResult = evalResult .GetProperty("result") .GetProperty("result") .GetProperty("value"); string candidates = string.Join("\n", rawResult.EnumerateArray().Select(c => c.GetString() ?? "")); Match match = Regex.Match(candidates, @"udp \d+ (\d{1,3}(?:\.\d{1,3}){3}) \d+ typ srflx"); return match.Groups[1].Value; } }