Automatic Port Selection and Secured Communication (#1038)

Adds dynamic OS-selected socket port handling and secured Electron/.NET communication.

The dotnet-first startup flow now generates the auth token on the .NET side and passes it to Electron through an environment variable. Electron reports the selected socket port through a temporary startup-info file, avoiding any dependency on parsing Electron console output.

Also avoids logging backend startup parameters because they include the auth token.
This commit is contained in:
Florian Rappl
2026-05-09 17:03:32 +02:00
committed by GitHub
parent 1e8b02648a
commit 11f71feeb8
21 changed files with 533 additions and 128 deletions

View File

@@ -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<string, string>
{
["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.

View File

@@ -1,6 +1,7 @@
namespace ElectronNET.Common
{
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
@@ -26,6 +27,8 @@
private readonly StringBuilder stdOut = new StringBuilder(4 * 1024);
private readonly StringBuilder stdErr = new StringBuilder(4 * 1024);
public event EventHandler<string> LineReceived;
private volatile ManualResetEvent stdOutEvent;
private volatile ManualResetEvent stdErrEvent;
private volatile Stopwatch stopwatch;
@@ -109,6 +112,11 @@
public int? LastExitCode { get; private set; }
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory)
{
return this.Run(exeFileName, commandLineArgs, workingDirectory, null);
}
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory, IDictionary<string, string> environmentVariables)
{
this.CommandLine = commandLineArgs;
this.WorkingFolder = workingDirectory;
@@ -126,6 +134,14 @@
WorkingDirectory = workingDirectory
};
if (environmentVariables != null)
{
foreach (var kv in environmentVariables)
{
startInfo.EnvironmentVariables[kv.Key] = kv.Value;
}
}
return this.Run(startInfo);
}
@@ -571,6 +587,7 @@
if (e.Data != null)
{
Console.WriteLine("|| " + e.Data);
LineReceived?.Invoke(this, e.Data);
}
else
{

View File

@@ -16,6 +16,7 @@
internal const int DefaultWebPort = 8001;
internal const string ElectronPortArgumentName = "electronPort";
internal const string ElectronPidArgumentName = "electronPID";
internal const string ElectronAuthTokenArgumentName = "electronAuthToken";
/// <summary>Initializes the <see cref="ElectronNetRuntime"/> class.</summary>
static ElectronNetRuntime()
@@ -26,6 +27,8 @@
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; }

View File

@@ -13,7 +13,6 @@
{
private ElectronProcessBase electronProcess;
private SocketBridgeService socketBridge;
private int? port;
public RuntimeControllerDotNetFirst()
{
@@ -41,19 +40,13 @@
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 @@
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();

View File

@@ -11,7 +11,6 @@
{
private ElectronProcessBase electronProcess;
private SocketBridgeService socketBridge;
private int? port;
public RuntimeControllerElectronFirst()
{
@@ -36,12 +35,8 @@
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 @@
}
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();

View File

@@ -1,10 +1,15 @@
namespace ElectronNET.Runtime.Services.ElectronProcess
{
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ElectronNET.Common;
using ElectronNET.Runtime.Data;
@@ -15,6 +20,9 @@
[Localizable(false)]
internal class ElectronProcessActive : ElectronProcessBase
{
private const string AuthTokenEnvVar = "ELECTRONNET_AUTH_TOKEN";
private const string StartupInfoEnvVar = "ELECTRONNET_STARTUP_INFO";
private readonly bool isUnpackaged;
private readonly string electronBinaryName;
private readonly string extraArguments;
@@ -88,8 +96,23 @@
workingDir = dir.FullName;
}
// Generate the auth token on the .NET side (256 bit entropy) and pass it
// to Electron via an environment variable. Electron will report the
// OS-selected port via a temporary handshake file - this avoids any
// dependency on parsing Electron's console output.
var authToken = CreateAuthToken();
var startupInfoPath = Path.Combine(
Path.GetTempPath(),
$"electronnet-startup-{Environment.ProcessId}-{Guid.NewGuid():N}.json");
// We don't await this in order to let the state transition to "Starting"
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir).ConfigureAwait(false));
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir, authToken, startupInfoPath).ConfigureAwait(false));
}
private static string CreateAuthToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private void CheckRuntimeIdentifier()
@@ -101,7 +124,6 @@
}
var osPart = buildInfoRid.Split('-').First();
var mismatch = false;
switch (osPart)
@@ -155,20 +177,56 @@
return Task.CompletedTask;
}
private async Task StartInternal(string startCmd, string args, string directoriy)
private async Task StartInternal(string startCmd, string args, string directoriy, string authToken, string startupInfoPath)
{
var tcs = new TaskCompletionSource();
using var cts = new CancellationTokenSource(2 * 60_000); // cancel after 2 minutes
using var _ = cts.Token.Register(() =>
{
// Time is over - let's kill the process and move on
this.process.Cancel();
// We don't want to raise exceptions here - just pass the barrier
tcs.TrySetResult();
});
void Monitor_SocketIO_Failure(object sender, EventArgs e)
{
// We don't want to raise exceptions here - just pass the barrier
if (tcs.Task.IsCompleted)
{
this.Process_Exited(sender, e);
}
else
{
tcs.TrySetResult();
}
}
try
{
await Task.Delay(10.ms()).ConfigureAwait(false);
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.Run(startCmd, args, directoriy);
this.process.ProcessExited += Monitor_SocketIO_Failure;
await Task.Delay(500.ms()).ConfigureAwait(false);
var env = new Dictionary<string, string>
{
[AuthTokenEnvVar] = authToken,
[StartupInfoEnvVar] = startupInfoPath,
};
this.process.Run(startCmd, args, directoriy, env);
// Wait for Electron to write the startup-info file (or for the process to die / timeout).
var waitTask = WaitForStartupInfoAsync(startupInfoPath, cts.Token);
var completed = await Task.WhenAny(waitTask, tcs.Task).ConfigureAwait(false);
int port = 0;
if (completed == waitTask && waitTask.Status == TaskStatus.RanToCompletion)
{
port = waitTask.Result;
}
Console.Error.WriteLine("[StartInternal]: after run:");
@@ -178,11 +236,18 @@
Console.Error.WriteLine("[StartInternal]: Process is not running: " + this.process.StandardOutput);
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
throw new Exception("Failed to launch the Electron process.");
}
this.TransitionState(LifetimeState.Ready);
else if (port > 0)
{
ElectronNetRuntime.ElectronAuthToken = authToken;
ElectronNetRuntime.ElectronSocketPort = port;
this.TransitionState(LifetimeState.Ready);
}
else
{
Console.Error.WriteLine("[StartInternal]: Did not receive Electron startup info before process exit/timeout.");
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
}
}
catch (Exception ex)
{
@@ -191,6 +256,63 @@
Console.Error.WriteLine("[StartInternal]: Exception: " + ex);
throw;
}
finally
{
try
{
if (File.Exists(startupInfoPath))
{
File.Delete(startupInfoPath);
}
}
catch
{
// best effort cleanup
}
}
}
private static async Task<int> WaitForStartupInfoAsync(string startupInfoPath, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
if (File.Exists(startupInfoPath))
{
var json = await File.ReadAllTextAsync(startupInfoPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(json))
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("port", out var portElement) &&
portElement.TryGetInt32(out var port) &&
port > 0)
{
return port;
}
}
}
}
catch (JsonException)
{
// File may be partially written / racing with the writer - retry.
}
catch (IOException)
{
// Same - transient races on file access; retry.
}
try
{
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
break;
}
}
return 0;
}
private void Process_Exited(object sender, EventArgs e)

View File

@@ -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 @@
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);

View File

@@ -106,6 +106,20 @@
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()

View File

@@ -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;
/// <summary>
/// Provides extension methods for <see cref="IWebHostBuilder"/> to enable Electron.NET
@@ -66,23 +68,26 @@
// 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<IStartupFilter, ServerReadyStartupFilter>();
services.AddSingleton<AspNetLifetimeAdapter>();

View File

@@ -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;
/// <summary>
/// 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
/// </summary>
public class ElectronAuthenticationMiddleware
{
private readonly RequestDelegate _next;
private readonly IElectronAuthenticationService _authService;
private readonly ILogger<ElectronAuthenticationMiddleware> _logger;
private const string AuthCookieName = "ElectronAuth";
public ElectronAuthenticationMiddleware(
RequestDelegate next,
IElectronAuthenticationService authService,
ILogger<ElectronAuthenticationMiddleware> 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");
}
}
}

View File

@@ -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 @@
}
}
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 @@
this.ElectronProcess.IsReady() &&
this.aspNetLifetimeAdapter.IsReady())
{
var token = ElectronNetRuntime.ElectronAuthToken;
var serverAddressesFeature = this.server.Features.Get<IServerAddressesFeature>();
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);
}

View File

@@ -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 @@
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 @@
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)

View File

@@ -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 @@
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;

View File

@@ -0,0 +1,53 @@
namespace ElectronNET.AspNet.Services
{
/// <summary>
/// Implementation of authentication service for Electron clients.
/// Stores and validates the authentication token to ensure only the spawned Electron process can connect.
/// </summary>
public class ElectronAuthenticationService : IElectronAuthenticationService
{
private string _expectedToken;
private readonly object _lock = new object();
/// <inheritdoc />
public void SetExpectedToken(string token)
{
lock (_lock)
{
_expectedToken = token;
}
}
/// <inheritdoc />
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);
}
}
/// <summary>
/// Performs constant-time string comparison to prevent timing attacks.
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,24 @@
namespace ElectronNET.AspNet.Services
{
/// <summary>
/// Service for validating authentication tokens from Electron clients.
/// Used to ensure only the Electron process spawned by this .NET instance can connect.
/// </summary>
public interface IElectronAuthenticationService
{
/// <summary>
/// Sets the expected authentication token for this instance.
/// Should be called when launching Electron with the generated token.
/// </summary>
/// <param name="token">The authentication token</param>
void SetExpectedToken(string token);
/// <summary>
/// Validates an incoming token against the expected token.
/// Uses constant-time comparison to prevent timing attacks.
/// </summary>
/// <param name="token">The token to validate</param>
/// <returns>True if token is valid, false otherwise</returns>
bool ValidateToken(string token);
}
}

View File

@@ -277,7 +277,15 @@ module.exports = (socket, 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 (app.commandLine.hasSwitch("clear-cache") &&
app.commandLine.getSwitchValue("clear-cache")) {

File diff suppressed because one or more lines are too long

View File

@@ -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 (

View File

@@ -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,26 @@ 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;
// Auth token: prefer the value provided by the .NET host via environment variable
// (dotnet-first startup). Fall back to a freshly generated token so Electron can
// still be launched stand-alone (e.g. for debugging).
let authToken = process.env.ELECTRONNET_AUTH_TOKEN || randomUUID().split('-').join('');
// Path to a temporary handshake file. When set by the .NET host, Electron writes
// the OS-selected socket port into this file so .NET does not have to parse the
// console output.
const startupInfoPath = process.env.ELECTRONNET_STARTUP_INFO;
if (app.commandLine.hasSwitch('manifest')) {
manifestJsonFileName = app.commandLine.getSwitchValue('manifest');
}
console.log('Entry!!!: ');
if (app.commandLine.hasSwitch('unpackedelectron')) {
unpackedelectron = true;
}
@@ -41,7 +51,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 +90,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 unpackaged mode, dir: ' + currentPath);
manifestJsonFilePath = path.join(currentPath, manifestJsonFileName);
currentBinPath = path.join(currentPath, '../'); // go to project directory
@@ -153,44 +170,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);
}
try {
apiProcess?.kill();
} catch (e) {
console.error(e);
}
try {
if (io && typeof io.close === 'function') {
io.close();
if (server) {
try {
server.close();
server.closeAllConnections();
} catch (e) {
console.error(e);
}
}
if (apiProcess) {
try {
apiProcess.kill();
} catch (e) {
console.error(e);
}
}
if (io && io.close) {
try {
io.close();
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
});
@@ -246,9 +257,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 +268,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 +278,34 @@ 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}`);
// If the .NET host requested a startup-info handshake, write the selected
// port atomically (tmp + rename) so .NET can pick it up without parsing
// our console output. The auth token is intentionally NOT written to disk
// - the .NET host already knows it (it generated it).
if (startupInfoPath) {
try {
const payload = JSON.stringify({ port: addr.port, pid: process.pid });
const tmp = `${startupInfoPath}.tmp`;
const writeOptions = platform() === 'win32'
? { encoding: 'utf8' }
: { encoding: 'utf8', mode: 0o600 };
fs.writeFileSync(tmp, payload, writeOptions);
fs.renameSync(tmp, startupInfoPath);
} catch (err) {
console.error('Failed to write Electron startup info file:', err);
}
}
// 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 +315,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 +344,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 +405,7 @@ function startSocketApiBridge(port) {
console.error(error.message);
}
console.log('Electron Socket: startup complete.');
console.info('Electron Socket: startup complete.');
});
}
@@ -383,23 +419,24 @@ 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(' '));
// Do not log the parameters: they include the auth token.
console.debug('Starting backend.');
apiProcess = cProcess(binFilePath, parameters, options);
apiProcess.stdout.on('data', (data) => {
console.log(`stdout: ${data.toString()}`);
console.debug(`stdout: ${data.toString()}`);
});
}
}
@@ -414,22 +451,23 @@ 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(' '));
// Do not log the parameters: they include the auth token.
console.debug('Starting backend (unpackaged).');
apiProcess = cProcess(binFilePath, parameters, options);
apiProcess.stdout.on('data', (data) => {
console.log(`stdout: ${data.toString()}`);
console.debug(`stdout: ${data.toString()}`);
});
}
}

View File

@@ -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"
},

View File

@@ -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"
},