diff --git a/src/ElectronNET.API/Bridge/SocketIOConnection.cs b/src/ElectronNET.API/Bridge/SocketIOConnection.cs index 7bf85675..36ea2e52 100644 --- a/src/ElectronNET.API/Bridge/SocketIOConnection.cs +++ b/src/ElectronNET.API/Bridge/SocketIOConnection.cs @@ -3,10 +3,12 @@ namespace ElectronNET.API; using System; +using System.Collections.Generic; using System.Threading.Tasks; using ElectronNET.API.Serialization; using SocketIO.Serializer.SystemTextJson; using SocketIO = SocketIOClient.SocketIO; +using SocketIOOptions = SocketIOClient.SocketIOOptions; internal class SocketIOConnection : ISocketConnection { @@ -14,9 +16,16 @@ internal class SocketIOConnection : ISocketConnection private readonly object _lockObj = new object(); private bool _isDisposed; - public SocketIOConnection(string uri) + public SocketIOConnection(string uri, string authorization) { - _socket = new SocketIO(uri); + var opts = string.IsNullOrEmpty(authorization) ? new SocketIOOptions() : new SocketIOOptions + { + ExtraHeaders = new Dictionary + { + ["authorization"] = authorization + }, + }; + _socket = new SocketIO(uri, opts); _socket.Serializer = new SystemTextJsonSerializer(ElectronJson.Options); // Use default System.Text.Json serializer from SocketIOClient. // Outgoing args are normalized to camelCase via SerializeArg in Emit. diff --git a/src/ElectronNET.API/Common/ProcessRunner.cs b/src/ElectronNET.API/Common/ProcessRunner.cs index 5ac7612a..d561020e 100644 --- a/src/ElectronNET.API/Common/ProcessRunner.cs +++ b/src/ElectronNET.API/Common/ProcessRunner.cs @@ -26,6 +26,8 @@ public class ProcessRunner : IDisposable private readonly StringBuilder stdOut = new StringBuilder(4 * 1024); private readonly StringBuilder stdErr = new StringBuilder(4 * 1024); + public event EventHandler LineReceived; + private volatile ManualResetEvent stdOutEvent; private volatile ManualResetEvent stdErrEvent; private volatile Stopwatch stopwatch; @@ -571,6 +573,7 @@ private void Process_OutputDataReceived(object sender, DataReceivedEventArgs e) if (e.Data != null) { Console.WriteLine("|| " + e.Data); + LineReceived?.Invoke(this, e.Data); } else { diff --git a/src/ElectronNET.API/ElectronNetRuntime.cs b/src/ElectronNET.API/ElectronNetRuntime.cs index e38fd935..3a285c4b 100644 --- a/src/ElectronNET.API/ElectronNetRuntime.cs +++ b/src/ElectronNET.API/ElectronNetRuntime.cs @@ -16,6 +16,7 @@ public static class ElectronNetRuntime internal const int DefaultWebPort = 8001; internal const string ElectronPortArgumentName = "electronPort"; internal const string ElectronPidArgumentName = "electronPID"; + internal const string ElectronAuthTokenArgumentName = "electronAuthToken"; /// Initializes the class. static ElectronNetRuntime() @@ -26,6 +27,8 @@ static ElectronNetRuntime() public static string ElectronExtraArguments { get; set; } + public static string ElectronAuthToken { get; internal set; } + public static int? ElectronSocketPort { get; internal set; } public static int? AspNetWebPort { get; internal set; } diff --git a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs index 0ceaeb8a..9a71fcde 100644 --- a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs +++ b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs @@ -13,7 +13,6 @@ internal class RuntimeControllerDotNetFirst : RuntimeControllerBase { private ElectronProcessBase electronProcess; private SocketBridgeService socketBridge; - private int? port; public RuntimeControllerDotNetFirst() { @@ -41,19 +40,13 @@ protected override Task StartCore() var isUnPacked = ElectronNetRuntime.StartupMethod.IsUnpackaged(); var electronBinaryName = ElectronNetRuntime.ElectronExecutable; var args = string.Format("{0} {1}", ElectronNetRuntime.ElectronExtraArguments, Environment.CommandLine).Trim(); - this.port = ElectronNetRuntime.ElectronSocketPort; - - if (!this.port.HasValue) - { - this.port = PortHelper.GetFreePort(ElectronNetRuntime.DefaultSocketPort); - ElectronNetRuntime.ElectronSocketPort = this.port; - } + var port = ElectronNetRuntime.ElectronSocketPort ?? 0; Console.Error.WriteLine("[StartCore]: isUnPacked: {0}", isUnPacked); Console.Error.WriteLine("[StartCore]: electronBinaryName: {0}", electronBinaryName); Console.Error.WriteLine("[StartCore]: args: {0}", args); - this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, this.port.Value); + this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, port); this.electronProcess.Ready += this.ElectronProcess_Ready; this.electronProcess.Stopped += this.ElectronProcess_Stopped; @@ -63,8 +56,10 @@ protected override Task StartCore() private void ElectronProcess_Ready(object sender, EventArgs e) { + var port = ElectronNetRuntime.ElectronSocketPort.Value; + var token = ElectronNetRuntime.ElectronAuthToken; this.TransitionState(LifetimeState.Started); - this.socketBridge = new SocketBridgeService(this.port!.Value); + this.socketBridge = new SocketBridgeService(port, token); this.socketBridge.Ready += this.SocketBridge_Ready; this.socketBridge.Stopped += this.SocketBridge_Stopped; this.socketBridge.Start(); diff --git a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs index 436ba921..a0ba3c60 100644 --- a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs +++ b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs @@ -11,7 +11,6 @@ internal class RuntimeControllerElectronFirst : RuntimeControllerBase { private ElectronProcessBase electronProcess; private SocketBridgeService socketBridge; - private int? port; public RuntimeControllerElectronFirst() { @@ -36,12 +35,8 @@ internal override ISocketConnection Socket protected override Task StartCore() { - this.port = ElectronNetRuntime.ElectronSocketPort; - - if (!this.port.HasValue) - { - throw new Exception("No port has been specified by Electron!"); - } + var port = ElectronNetRuntime.ElectronSocketPort.Value; + var token = ElectronNetRuntime.ElectronAuthToken; if (!ElectronNetRuntime.ElectronProcessId.HasValue) { @@ -49,7 +44,7 @@ protected override Task StartCore() } this.TransitionState(LifetimeState.Starting); - this.socketBridge = new SocketBridgeService(this.port!.Value); + this.socketBridge = new SocketBridgeService(port, token); this.socketBridge.Ready += this.SocketBridge_Ready; this.socketBridge.Stopped += this.SocketBridge_Stopped; this.socketBridge.Start(); diff --git a/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs b/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs index b2d32a9e..9af3543b 100644 --- a/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs +++ b/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs @@ -2,9 +2,11 @@ { using System; using System.ComponentModel; + using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; + using System.Text.RegularExpressions; using System.Threading.Tasks; using ElectronNET.Common; using ElectronNET.Runtime.Data; @@ -15,6 +17,7 @@ [Localizable(false)] internal class ElectronProcessActive : ElectronProcessBase { + private readonly Regex extractor = new Regex("^Electron Socket: listening on port (\\d+) at .* using ([a-f0-9]+)$"); private readonly bool isUnpackaged; private readonly string electronBinaryName; private readonly string extraArguments; @@ -157,18 +160,36 @@ protected override Task StopCore() private async Task StartInternal(string startCmd, string args, string directoriy) { - try + var tcs = new TaskCompletionSource(); + + void Read_SocketIO_Parameters(object sender, string line) { - await Task.Delay(10.ms()).ConfigureAwait(false); + // Look for "Electron Socket: listening on port %s at ..." + var match = extractor.Match(line); + if (match?.Success ?? false) + { + var port = int.Parse(match.Groups[1].Value); + var token = match.Groups[2].Value; + + this.process.LineReceived -= Read_SocketIO_Parameters; + ElectronNetRuntime.ElectronAuthToken = token; + ElectronNetRuntime.ElectronSocketPort = port; + tcs.SetResult(); + } + } + + try + { Console.Error.WriteLine("[StartInternal]: startCmd: {0}", startCmd); Console.Error.WriteLine("[StartInternal]: args: {0}", args); this.process = new ProcessRunner("ElectronRunner"); this.process.ProcessExited += this.Process_Exited; + this.process.LineReceived += Read_SocketIO_Parameters; this.process.Run(startCmd, args, directoriy); - await Task.Delay(500.ms()).ConfigureAwait(false); + await tcs.Task.ConfigureAwait(false); Console.Error.WriteLine("[StartInternal]: after run:"); diff --git a/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs b/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs index 7200f496..4530a651 100644 --- a/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs +++ b/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs @@ -8,12 +8,14 @@ internal class SocketBridgeService : LifetimeServiceBase { private readonly int socketPort; + private readonly string authorization; private readonly string socketUrl; private SocketIOConnection socket; - public SocketBridgeService(int socketPort) + public SocketBridgeService(int socketPort, string authorization) { this.socketPort = socketPort; + this.authorization = authorization; this.socketUrl = $"http://localhost:{this.socketPort}"; } @@ -23,7 +25,7 @@ public SocketBridgeService(int socketPort) protected override Task StartCore() { - this.socket = new SocketIOConnection(this.socketUrl); + this.socket = new SocketIOConnection(this.socketUrl, this.authorization); this.socket.BridgeConnected += this.Socket_BridgeConnected; this.socket.BridgeDisconnected += this.Socket_BridgeDisconnected; Task.Run(this.Connect); diff --git a/src/ElectronNET.API/Runtime/StartupManager.cs b/src/ElectronNET.API/Runtime/StartupManager.cs index 91fed9a2..e7ceff17 100644 --- a/src/ElectronNET.API/Runtime/StartupManager.cs +++ b/src/ElectronNET.API/Runtime/StartupManager.cs @@ -106,6 +106,20 @@ private void CollectProcessData() Console.WriteLine("Electron Process ID: " + result); } } + + var authTokenArg = argsList.FirstOrDefault(e => e.Contains(ElectronNetRuntime.ElectronAuthTokenArgumentName, StringComparison.OrdinalIgnoreCase)); + + if (authTokenArg != null) + { + var parts = authTokenArg.Split('=', StringSplitOptions.TrimEntries); + + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) + { + var result = parts[1]; + ElectronNetRuntime.ElectronAuthToken = result; + Console.WriteLine("Use Auth Token: " + result); + } + } } private void SetElectronExecutable() diff --git a/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs b/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs index 26d524f7..bbd41e70 100644 --- a/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs +++ b/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs @@ -1,6 +1,7 @@ namespace ElectronNET.API { using System; + using System.Diagnostics; using System.IO; using System.Threading.Tasks; using ElectronNET.AspNet; @@ -10,6 +11,7 @@ using ElectronNET.Runtime.Helpers; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; /// /// Provides extension methods for to enable Electron.NET @@ -66,23 +68,26 @@ public static IWebHostBuilder UseElectron(this IWebHostBuilder builder, string[] // work as expected, see issue #952 Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", null); - var webPort = PortHelper.GetFreePort(ElectronNetRuntime.AspNetWebPort ?? ElectronNetRuntime.DefaultWebPort); - ElectronNetRuntime.AspNetWebPort = webPort; + var webPort = ElectronNetRuntime.AspNetWebPort ?? 0; // check for the content folder if its exists in base director otherwise no need to include // It was used before because we are publishing the project which copies everything to bin folder and contentroot wwwroot was folder there. // now we have implemented the live reload if app is run using /watch then we need to use the default project path. + + // For port 0 (dynamic port assignment), Kestrel requires binding to specific IP (127.0.0.1) not localhost + var host = webPort == 0? "127.0.0.1" : "localhost"; + if (Directory.Exists($"{AppDomain.CurrentDomain.BaseDirectory}\\wwwroot")) { builder = builder.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory) - .UseUrls("http://localhost:" + webPort); + .UseUrls($"http://{host}:{webPort}"); } else { - builder = builder.UseUrls("http://localhost:" + webPort); + builder = builder.UseUrls($"http://{host}:{webPort}"); } - builder = builder.ConfigureServices(services => + builder = builder.ConfigureServices((context, services) => { services.AddTransient(); services.AddSingleton(); diff --git a/src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs b/src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs new file mode 100644 index 00000000..2303288c --- /dev/null +++ b/src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs @@ -0,0 +1,100 @@ +namespace ElectronNET.AspNet.Middleware +{ + using System; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using Microsoft.Extensions.Logging; + using ElectronNET.AspNet.Services; + + /// + /// Middleware that validates authentication for all Electron requests. + /// Checks for authentication cookie or token query parameter on first request. + /// Sets HttpOnly cookie for subsequent requests. + /// + /// Security Model: + /// - First request includes token as query parameter (?token=guid) + /// - Middleware validates token and sets secure HttpOnly cookie + /// - Subsequent requests use cookie (no token in URL) + /// - HTTP endpoints protected + /// + public class ElectronAuthenticationMiddleware + { + private readonly RequestDelegate _next; + private readonly IElectronAuthenticationService _authService; + private readonly ILogger _logger; + private const string AuthCookieName = "ElectronAuth"; + + public ElectronAuthenticationMiddleware( + RequestDelegate next, + IElectronAuthenticationService authService, + ILogger logger) + { + _next = next; + _authService = authService; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value; + + // Check if authentication cookie exists + var authCookie = context.Request.Cookies[AuthCookieName]; + + if (!string.IsNullOrEmpty(authCookie)) + { + // Cookie present - validate it + if (_authService.ValidateToken(authCookie)) + { + await _next(context); + return; + } + else + { + // Invalid cookie - reject + _logger.LogWarning("Authentication failed: Invalid cookie for path {Path} from {RemoteIp}", path, context.Connection.RemoteIpAddress); + context.Response.StatusCode = 401; + await context.Response.WriteAsync("Unauthorized: Invalid authentication"); + return; + } + } + + // No cookie - check for token in query string (first-time authentication) + var token = context.Request.Query["token"].ToString(); + + if (!string.IsNullOrEmpty(token)) + { + if (_authService.ValidateToken(token)) + { + // Valid token - set cookie for future requests + _logger.LogInformation("Authentication successful: Setting cookie for path {Path}", path); + + context.Response.Cookies.Append(AuthCookieName, token, new CookieOptions + { + HttpOnly = true, // Prevent JavaScript access (XSS protection) + SameSite = SameSiteMode.Strict, // CSRF protection + Path = "/", // Valid for all routes + Secure = false, // False because localhost is HTTP + IsEssential = true // Required for app to function + }); + + await _next(context); + return; + } + else + { + // Invalid token - reject + _logger.LogWarning("Authentication failed: Invalid token (prefix: {TokenPrefix}...) for path {Path} from {RemoteIp}", token.Length > 8 ? token.Substring(0, 8) : token, path, context.Connection.RemoteIpAddress); + context.Response.StatusCode = 401; + await context.Response.WriteAsync("Unauthorized: Invalid authentication"); + return; + } + } + + // Neither cookie nor valid token present - reject + _logger.LogWarning("Authentication failed: No cookie or token provided for path {Path} from {RemoteIp}", path, context.Connection.RemoteIpAddress); + context.Response.StatusCode = 401; + await context.Response.WriteAsync("Unauthorized: Authentication required"); + } + } +} \ No newline at end of file diff --git a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs index ab807f3a..b7d2f338 100644 --- a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs +++ b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs @@ -1,8 +1,13 @@ namespace ElectronNET.AspNet.Runtime { using System; + using System.Linq; using System.Threading.Tasks; + using Microsoft.AspNetCore.Hosting.Server; + using Microsoft.AspNetCore.Hosting.Server.Features; + using Microsoft.Extensions.DependencyInjection; using ElectronNET.API; + using ElectronNET.AspNet.Services; using ElectronNET.Common; using ElectronNET.Runtime.Controllers; using ElectronNET.Runtime.Data; @@ -10,12 +15,16 @@ internal abstract class RuntimeControllerAspNetBase : RuntimeControllerBase { + private readonly IServer server; private readonly AspNetLifetimeAdapter aspNetLifetimeAdapter; + private readonly IElectronAuthenticationService authenticationService; private SocketBridgeService socketBridge; - protected RuntimeControllerAspNetBase(AspNetLifetimeAdapter aspNetLifetimeAdapter) + protected RuntimeControllerAspNetBase(IServer server, AspNetLifetimeAdapter aspNetLifetimeAdapter, IElectronAuthenticationService authenticationService = null) { + this.server = server; this.aspNetLifetimeAdapter = aspNetLifetimeAdapter; + this.authenticationService = authenticationService; this.aspNetLifetimeAdapter.Ready += this.AspNetLifetimeAdapter_Ready; this.aspNetLifetimeAdapter.Stopping += this.AspNetLifetimeAdapter_Stopping; this.aspNetLifetimeAdapter.Stopped += this.AspNetLifetimeAdapter_Stopped; @@ -38,9 +47,9 @@ internal override ISocketConnection Socket } } - protected void CreateSocketBridge(int port) + protected void CreateSocketBridge(int port, string authorization) { - this.socketBridge = new SocketBridgeService(port); + this.socketBridge = new SocketBridgeService(port, authorization); this.socketBridge.Ready += this.SocketBridge_Ready; this.socketBridge.Stopped += this.SocketBridge_Stopped; this.socketBridge.Start(); @@ -52,6 +61,15 @@ protected void HandleReady() this.ElectronProcess.IsReady() && this.aspNetLifetimeAdapter.IsReady()) { + var token = ElectronNetRuntime.ElectronAuthToken; + var serverAddressesFeature = this.server.Features.Get(); + var address = serverAddressesFeature.Addresses.First(); + var uri = new Uri(address); + + // Only if somebody registered an IElectronAuthenticationService service - otherwise we do not care + this.authenticationService?.SetExpectedToken(token); + ElectronNetRuntime.AspNetWebPort = uri.Port; + this.TransitionState(LifetimeState.Ready); Task.Run(this.RunReadyCallback); } diff --git a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs index 4c762915..7a94732a 100644 --- a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs +++ b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs @@ -2,6 +2,9 @@ { using System; using System.Threading.Tasks; + using System.Security.Principal; + using Microsoft.AspNetCore.Hosting.Server; + using ElectronNET.AspNet.Services; using ElectronNET.Common; using ElectronNET.Runtime.Data; using ElectronNET.Runtime.Helpers; @@ -10,9 +13,8 @@ internal class RuntimeControllerAspNetDotnetFirst : RuntimeControllerAspNetBase { private ElectronProcessBase electronProcess; - private int? port; - public RuntimeControllerAspNetDotnetFirst(AspNetLifetimeAdapter aspNetLifetimeAdapter) : base(aspNetLifetimeAdapter) + public RuntimeControllerAspNetDotnetFirst(IServer server, AspNetLifetimeAdapter aspNetLifetimeAdapter, IElectronAuthenticationService authenticationService = null) : base(server, aspNetLifetimeAdapter, authenticationService) { } @@ -23,15 +25,9 @@ protected override Task StartCore() var isUnPacked = ElectronNetRuntime.StartupMethod.IsUnpackaged(); var electronBinaryName = ElectronNetRuntime.ElectronExecutable; var args = Environment.CommandLine; - this.port = ElectronNetRuntime.ElectronSocketPort; + var port = ElectronNetRuntime.ElectronSocketPort ?? 0; - if (!this.port.HasValue) - { - this.port = PortHelper.GetFreePort(ElectronNetRuntime.DefaultSocketPort); - ElectronNetRuntime.ElectronSocketPort = this.port; - } - - this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, this.port.Value); + this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, port); this.electronProcess.Ready += this.ElectronProcess_Ready; this.electronProcess.Stopped += this.ElectronProcess_Stopped; @@ -46,8 +42,10 @@ protected override Task StopCore() private void ElectronProcess_Ready(object sender, EventArgs e) { + var port = ElectronNetRuntime.ElectronSocketPort.Value; + var token = ElectronNetRuntime.ElectronAuthToken; this.TransitionState(LifetimeState.Started); - this.CreateSocketBridge(this.port!.Value); + this.CreateSocketBridge(port, token); } private void ElectronProcess_Stopped(object sender, EventArgs e) diff --git a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs index c9eb0697..757507d0 100644 --- a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs +++ b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs @@ -2,15 +2,16 @@ { using System; using System.Threading.Tasks; + using Microsoft.AspNetCore.Hosting.Server; + using ElectronNET.AspNet.Services; using ElectronNET.Runtime.Data; using ElectronNET.Runtime.Services.ElectronProcess; internal class RuntimeControllerAspNetElectronFirst : RuntimeControllerAspNetBase { private ElectronProcessBase electronProcess; - private int? port; - public RuntimeControllerAspNetElectronFirst(AspNetLifetimeAdapter aspNetLifetimeAdapter) : base(aspNetLifetimeAdapter) + public RuntimeControllerAspNetElectronFirst(IServer server, AspNetLifetimeAdapter aspNetLifetimeAdapter, IElectronAuthenticationService authenticationService = null) : base(server, aspNetLifetimeAdapter, authenticationService) { } @@ -18,19 +19,15 @@ public RuntimeControllerAspNetElectronFirst(AspNetLifetimeAdapter aspNetLifetime protected override Task StartCore() { - this.port = ElectronNetRuntime.ElectronSocketPort; - - if (!this.port.HasValue) - { - throw new Exception("No port has been specified by Electron!"); - } + var port = ElectronNetRuntime.ElectronSocketPort.Value; + var token = ElectronNetRuntime.ElectronAuthToken; if (!ElectronNetRuntime.ElectronProcessId.HasValue) { throw new Exception("No electronPID has been specified by Electron!"); } - this.CreateSocketBridge(this.port!.Value); + this.CreateSocketBridge(port, token); this.electronProcess = new ElectronProcessPassive(ElectronNetRuntime.ElectronProcessId.Value); this.electronProcess.Stopped += this.ElectronProcess_Stopped; diff --git a/src/ElectronNET.AspNet/Services/ElectronAuthenticationService.cs b/src/ElectronNET.AspNet/Services/ElectronAuthenticationService.cs new file mode 100644 index 00000000..9d0fbb07 --- /dev/null +++ b/src/ElectronNET.AspNet/Services/ElectronAuthenticationService.cs @@ -0,0 +1,53 @@ +namespace ElectronNET.AspNet.Services +{ + /// + /// Implementation of authentication service for Electron clients. + /// Stores and validates the authentication token to ensure only the spawned Electron process can connect. + /// + public class ElectronAuthenticationService : IElectronAuthenticationService + { + private string _expectedToken; + private readonly object _lock = new object(); + + /// + public void SetExpectedToken(string token) + { + lock (_lock) + { + _expectedToken = token; + } + } + + /// + public bool ValidateToken(string token) + { + if (string.IsNullOrEmpty(token)) + return false; + + lock (_lock) + { + if (string.IsNullOrEmpty(_expectedToken)) + return false; + + // Constant-time comparison to prevent timing attacks + return ConstantTimeEquals(token, _expectedToken); + } + } + + /// + /// Performs constant-time string comparison to prevent timing attacks. + /// + private static bool ConstantTimeEquals(string a, string b) + { + if (a == null || b == null || a.Length != b.Length) + return false; + + var result = 0; + for (int i = 0; i < a.Length; i++) + { + result |= a[i] ^ b[i]; + } + return result == 0; + } + } +} \ No newline at end of file diff --git a/src/ElectronNET.AspNet/Services/IElectronAuthenticationService.cs b/src/ElectronNET.AspNet/Services/IElectronAuthenticationService.cs new file mode 100644 index 00000000..0208242c --- /dev/null +++ b/src/ElectronNET.AspNet/Services/IElectronAuthenticationService.cs @@ -0,0 +1,24 @@ +namespace ElectronNET.AspNet.Services +{ + /// + /// Service for validating authentication tokens from Electron clients. + /// Used to ensure only the Electron process spawned by this .NET instance can connect. + /// + public interface IElectronAuthenticationService + { + /// + /// Sets the expected authentication token for this instance. + /// Should be called when launching Electron with the generated token. + /// + /// The authentication token + void SetExpectedToken(string token); + + /// + /// Validates an incoming token against the expected token. + /// Uses constant-time comparison to prevent timing attacks. + /// + /// The token to validate + /// True if token is valid, false otherwise + bool ValidateToken(string token); + } +} \ No newline at end of file diff --git a/src/ElectronNET.Host/api/browserWindows.ts b/src/ElectronNET.Host/api/browserWindows.ts index a8cd92d0..5bccc427 100644 --- a/src/ElectronNET.Host/api/browserWindows.ts +++ b/src/ElectronNET.Host/api/browserWindows.ts @@ -6,6 +6,7 @@ import { browserViewMediateService } from "./browserView"; const windows: Electron.BrowserWindow[] = (global["browserWindows"] = global["browserWindows"] || []) as Electron.BrowserWindow[]; + let readyToShowWindowsIds: number[] = []; let window; @@ -308,7 +309,15 @@ export = (socket: Socket, app: Electron.App) => { }); if (loadUrl) { - window.loadURL(loadUrl); + // Append authentication token to initial URL if available + const token = global["authToken"]; + + if (token) { + const separator = loadUrl.includes("?") ? "&" : "?"; + window.loadURL(`${loadUrl}${separator}token=${token}`); + } else { + window.loadURL(loadUrl); + } } if ( diff --git a/src/ElectronNET.Host/main.js b/src/ElectronNET.Host/main.js index 9ea21ded..56472acc 100644 --- a/src/ElectronNET.Host/main.js +++ b/src/ElectronNET.Host/main.js @@ -1,10 +1,14 @@ const { app } = require('electron'); const { BrowserWindow } = require('electron'); -const { protocol } = require('electron'); +const { createServer } = require('http'); +const { randomUUID } = require('crypto'); +const { Server } = require('socket.io'); +const { platform } = require('os'); const path = require('path'); +const fs = require('fs'); const cProcess = require('child_process').spawn; -const portscanner = require('portscanner'); const { imageSize } = require('image-size'); + let io, server, browserWindows, ipc, apiProcess, loadURL; let appApi, menu, dialogApi, notification, tray, webContents; let globalShortcut, shellApi, screen, clipboard, autoUpdater; @@ -16,20 +20,19 @@ let nativeTheme; let dock; let launchFile; let launchUrl; -let processApi; let manifestJsonFileName = 'package.json'; let unpackedelectron = false; let unpackeddotnet = false; let dotnetpacked = false; let electronforcedport; +let electronUrl; +let authToken = randomUUID().split('-').join(''); if (app.commandLine.hasSwitch('manifest')) { manifestJsonFileName = app.commandLine.getSwitchValue('manifest'); } -console.log('Entry!!!: '); - if (app.commandLine.hasSwitch('unpackedelectron')) { unpackedelectron = true; } @@ -41,7 +44,14 @@ else if (app.commandLine.hasSwitch('dotnetpacked')) { } if (app.commandLine.hasSwitch('electronforcedport')) { - electronforcedport = app.commandLine.getSwitchValue('electronforcedport'); + electronforcedport = +app.commandLine.getSwitchValue('electronforcedport'); +} + +// Store in global for access by browser windows +global.authToken = authToken; + +if (app.commandLine.hasSwitch('electronurl')) { + electronUrl = app.commandLine.getSwitchValue('electronurl'); } // Custom startup hook: look for custom_main.js and invoke its onStartup(host) if present. @@ -73,7 +83,7 @@ let manifestJsonFilePath = path.join(currentPath, manifestJsonFileName); // if running unpackedelectron, lets change the path if (unpackedelectron || unpackeddotnet) { - console.log('unpackedelectron! dir: ' + currentPath); + console.debug('Running in unpacked mode, dir: ' + currentPath); manifestJsonFilePath = path.join(currentPath, manifestJsonFileName); currentBinPath = path.join(currentPath, '../'); // go to project directory @@ -153,44 +163,38 @@ app.on('ready', () => { } if (electronforcedport) { - console.log('Electron Socket IO (forced) Port: ' + electronforcedport); + console.info('Electron Socket IO (forced) Port: ' + electronforcedport); startSocketApiBridge(electronforcedport); - return; + } else { + console.info('Electron Socket dynamic IO Port'); + startSocketApiBridge(0); } - - // Added default port as configurable for port restricted environments. - let defaultElectronPort = 8000; - if (manifestJsonFile.electronPort) { - defaultElectronPort = manifestJsonFile.electronPort; - } - - // hostname needs to be localhost, otherwise Windows Firewall will be triggered. - portscanner.findAPortNotInUse(defaultElectronPort, 65535, 'localhost', function (error, port) { - console.log('Electron Socket IO Port: ' + port); - startSocketApiBridge(port); - }); }); app.on('quit', async (event, exitCode) => { - try { - server.close(); - server.closeAllConnections(); - } catch (e) { - console.error(e); + if (server) { + try { + server.close(); + server.closeAllConnections(); + } catch (e) { + console.error(e); + } } - try { - apiProcess?.kill(); - } catch (e) { - console.error(e); + if (apiProcess) { + try { + apiProcess.kill(); + } catch (e) { + console.error(e); + } } - try { - if (io && typeof io.close === 'function') { + if (io && io.close) { + try { io.close(); + } catch (e) { + console.error(e); } - } catch (e) { - console.error(e); } }); @@ -246,9 +250,7 @@ function startSplashScreen() { // it's an image, so we can compute the desired splash screen size imageSize(imageFile, (error, dimensions) => { if (error) { - console.log(`load splashscreen error:`); - console.error(error); - + console.error(`load splashscreen error:`, error); throw new Error(error.message); } @@ -259,9 +261,9 @@ function startSplashScreen() { function startSocketApiBridge(port) { // instead of 'require('socket.io')(port);' we need to use this workaround // otherwise the Windows Firewall will be triggered - console.log('Electron Socket: starting...'); - server = require('http').createServer(); - const { Server } = require('socket.io'); + console.debug('Electron Socket: starting...'); + server = createServer(); + const host = !port ? '127.0.0.1' : 'localhost'; let hostHook; io = new Server({ pingTimeout: 60000, // in ms, default is 5000 @@ -269,14 +271,16 @@ function startSocketApiBridge(port) { }); io.attach(server); - server.listen(port, 'localhost'); + server.listen(port, host); server.on('listening', function () { - console.log('Electron Socket: listening on port %s at %s', server.address().port, server.address().address); + const addr = server.address(); + console.info(`Electron Socket: listening on port ${addr.port} at ${addr.address} using ${authToken}`); + // Now that socket connection is established, we can guarantee port will not be open for portscanner if (unpackedelectron) { - startAspCoreBackendUnpackaged(port); + startAspCoreBackendUnpackaged(addr.port); } else if (!unpackeddotnet && !dotnetpacked) { - startAspCoreBackend(port); + startAspCoreBackend(addr.port); } }); @@ -286,9 +290,16 @@ function startSocketApiBridge(port) { // @ts-ignore io.on('connection', (socket) => { - console.log('Electron Socket: connected!'); + console.info('Electron Socket: connected!'); + + if (authToken && socket.request.headers.authorization !== authToken) { + console.warn('Electron Socket authentication failed!'); + socket.disconnect(true); + return; + } + socket.on('disconnect', function (reason) { - console.log('Got disconnect! Reason: ' + reason); + console.debug('Got disconnect! Reason: ' + reason); try { ////console.log('requireCache'); ////console.log(require.cache['electron-host-hook']); @@ -308,7 +319,7 @@ function startSocketApiBridge(port) { global['electronsocket'].setMaxListeners(0); } - console.log('Electron Socket: loading components...'); + console.debug('Electron Socket: loading components...'); if (appApi === undefined) appApi = require('./api/app')(socket, app); if (browserWindows === undefined) browserWindows = require('./api/browserWindows')(socket, app); @@ -369,7 +380,7 @@ function startSocketApiBridge(port) { console.error(error.message); } - console.log('Electron Socket: startup complete.'); + console.info('Electron Socket: startup complete.'); }); } @@ -383,23 +394,23 @@ function startAspCoreBackend(electronPort) { envParam, `/electronPort=${electronPort}`, `/electronPID=${process.pid}`, + `/electronAuthToken=${authToken}`, // forward user supplied args (avoid duplicate environment) ...forwardedArgs.filter(a => !(envParam && a.startsWith('--environment='))) ].filter(p => p); let binaryFile = manifestJsonFile.executable; - const os = require('os'); - if (os.platform() === 'win32') { + if (platform() === 'win32') { binaryFile = binaryFile + '.exe'; } let binFilePath = path.join(currentBinPath, binaryFile); var options = { cwd: currentBinPath }; - console.log('Starting backend with parameters:', parameters.join(' ')); + console.debug('Starting backend with parameters:', parameters.join(' ')); apiProcess = cProcess(binFilePath, parameters, options); apiProcess.stdout.on('data', (data) => { - console.log(`stdout: ${data.toString()}`); + console.debug(`stdout: ${data.toString()}`); }); } } @@ -414,22 +425,22 @@ function startAspCoreBackendUnpackaged(electronPort) { envParam, `/electronPort=${electronPort}`, `/electronPID=${process.pid}`, + `/electronAuthToken=${authToken}`, ...forwardedArgs.filter(a => !(envParam && a.startsWith('--environment='))) ].filter(p => p); let binaryFile = manifestJsonFile.executable; - const os = require('os'); - if (os.platform() === 'win32') { + if (platform() === 'win32') { binaryFile = binaryFile + '.exe'; } let binFilePath = path.join(currentBinPath, binaryFile); var options = { cwd: currentBinPath }; - console.log('Starting backend (unpackaged) with parameters:', parameters.join(' ')); + console.debug('Starting backend (unpackaged) with parameters:', parameters.join(' ')); apiProcess = cProcess(binFilePath, parameters, options); apiProcess.stdout.on('data', (data) => { - console.log(`stdout: ${data.toString()}`); + console.debug(`stdout: ${data.toString()}`); }); } } diff --git a/src/ElectronNET.Host/package.json b/src/ElectronNET.Host/package.json index 95bfc01c..519dff5b 100644 --- a/src/ElectronNET.Host/package.json +++ b/src/ElectronNET.Host/package.json @@ -19,7 +19,6 @@ "dasherize": "^2.0.0", "electron-host-hook": "file:./ElectronHostHook", "image-size": "^1.2.1", - "portscanner": "^2.2.0", "electron-updater": "^6.6.2", "socket.io": "^4.8.1" }, diff --git a/src/ElectronNET/build/package.template.json b/src/ElectronNET/build/package.template.json index 0f559489..fa5bca12 100644 --- a/src/ElectronNET/build/package.template.json +++ b/src/ElectronNET/build/package.template.json @@ -35,7 +35,6 @@ "dasherize": "^2.0.0", "electron-updater": "^6.6.2", "image-size": "^1.2.1", - "portscanner": "^2.2.0", "socket.io": "^4.8.1", "electron-host-hook": "file:./ElectronHostHook" },