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

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