diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index d40e629..891a732 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -64,18 +64,15 @@ public async Task Post() { string jsonData = await reader.ReadToEndAsync(); - bool bSecureWS = true; - //if (ipCountry2LISO.ToLower() == "ru") - { - //bSecureWS = false; - } - - POST_CheckLogin_Result result = (POST_CheckLogin_Result)await Post_InternalHandler(jsonData, IPHelpers.NormalizeIP(HttpContext.Connection.RemoteIpAddress?.ToString()), bSecureWS); + POST_CheckLogin_Result result = (POST_CheckLogin_Result)await Post_InternalHandler( + jsonData, + IPHelpers.NormalizeIP(HttpContext.Connection.RemoteIpAddress?.ToString()), + Program.BuildWebSocketUrl(Request)); return result; } } - public async Task Post_InternalHandler(string jsonData, string ipAddr, bool bSecureWS, bool bIsMonitor = false) + public async Task Post_InternalHandler(string jsonData, string ipAddr, string webSocketUrl, bool bIsMonitor = false) { POST_CheckLogin_Result result = new POST_CheckLogin_Result(); @@ -218,7 +215,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr result.refresh_token = refreshtoken; result.user_id = user_id; result.display_name = strDisplayName; - result.ws_uri = Program.GetWebSocketAddress(bSecureWS); + result.ws_uri = webSocketUrl; // clear cached data, its a new session and the client reconnects its // websocket using the ws_uri below - must be awaited so the teardown diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index e20d2c1..1809ff2 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -66,18 +66,15 @@ public async Task Post() { string jsonData = await reader.ReadToEndAsync(); - bool bSecureWS = true; - //if (ipCountry2LISO.ToLower() == "ru") - { - //bSecureWS = false; - } - - POST_LoginWithToken_Result result = (POST_LoginWithToken_Result)await Post_InternalHandler(jsonData, IPHelpers.NormalizeIP(HttpContext.Connection.RemoteIpAddress?.ToString()), bSecureWS); + POST_LoginWithToken_Result result = (POST_LoginWithToken_Result)await Post_InternalHandler( + jsonData, + IPHelpers.NormalizeIP(HttpContext.Connection.RemoteIpAddress?.ToString()), + Program.BuildWebSocketUrl(Request)); return result; } } - public async Task Post_InternalHandler(string jsonData, string ipAddr, bool bSecureWS, bool bWasMonitor = false) + public async Task Post_InternalHandler(string jsonData, string ipAddr, string webSocketUrl, bool bWasMonitor = false) { if (bWasMonitor) { @@ -164,7 +161,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr result.user_id = user_id; result.display_name = strDisplayName; - result.ws_uri = Program.GetWebSocketAddress(bSecureWS); + result.ws_uri = webSocketUrl; // This endpoint re-establishes a session, so tear down any state the previous // one left behind (lobby membership, matchmaking, cached session data). Must diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index 8afd360..264672d 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -224,7 +224,7 @@ public async Task Monitor_Database() var factory = scope.ServiceProvider.GetRequiredService>(); GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(factory); - GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result internalResult = (GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result)await loginWithTokenController.Post_InternalHandler("{\"challenge\": \"abc\", \"token\": \"iamatest\", \"client_id\": \"gen_online_60hz\"}", IPAddress.Loopback.ToString(), true); + GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result internalResult = (GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result)await loginWithTokenController.Post_InternalHandler("{\"challenge\": \"abc\", \"token\": \"iamatest\", \"client_id\": \"gen_online_60hz\"}", IPAddress.Loopback.ToString(), Program.BuildWebSocketUrl(Request)); return internalResult; } catch @@ -302,7 +302,7 @@ public APIResult Monitor_Uptime() try { GenOnlineService.Controllers.CheckLoginController checkLoginController = new GenOnlineService.Controllers.CheckLoginController(_dbFactory); - APIResult internalResult = await checkLoginController.Post_InternalHandler("{\"challenge\": \"abc\", \"nonce\": \"def\", \"code\": \"iamatest\", \"client_id\": \"gen_online_30hz\"}", IPAddress.Loopback.ToString(), true); + APIResult internalResult = await checkLoginController.Post_InternalHandler("{\"challenge\": \"abc\", \"nonce\": \"def\", \"code\": \"iamatest\", \"client_id\": \"gen_online_30hz\"}", IPAddress.Loopback.ToString(), Program.BuildWebSocketUrl(Request)); return internalResult; } catch diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index b9b823d..7f2574b 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -20,7 +20,10 @@ using MaxMind.GeoIP2; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.WebSockets; @@ -36,6 +39,7 @@ using System.Configuration; using System.Drawing; using System.IdentityModel.Tokens.Jwt; +using System.Net; using System.Net.Http.Headers; using System.Net.WebSockets; using System.Security.Claims; @@ -756,31 +760,131 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo } } - public static string GetWebSocketAddress(bool bSecure) + private static CorsPolicy BuildCorsPolicy(IConfiguration configuration) { - if (Program.g_Config == null) + string[] configuredOrigins = configuration + .GetSection("AllowedOrigins") + .Get() ?? Array.Empty(); + + string[] allowedOrigins = configuredOrigins + .Select(origin => origin.Trim()) + .Where(origin => origin.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + bool allowAnyOrigin = allowedOrigins.Contains("*"); + if (allowAnyOrigin && allowedOrigins.Length > 1) { - throw new Exception("g_Config is null."); + throw new InvalidOperationException("AllowedOrigins cannot combine '*' with explicit origins."); } - IConfiguration? coreSettings = Program.g_Config.GetSection("Core"); + var policyBuilder = new CorsPolicyBuilder() + .AllowAnyHeader() + .AllowAnyMethod(); - if (coreSettings == null) + if (allowAnyOrigin) { - throw new Exception("Core section of config is null."); + // CORS forbids combining any-origin with credential sharing. + policyBuilder.AllowAnyOrigin(); } + else if (allowedOrigins.Length > 0) + { + policyBuilder.WithOrigins(allowedOrigins) + .SetIsOriginAllowedToAllowWildcardSubdomains() + .AllowCredentials(); + } + + return policyBuilder.Build(); + } + + private static bool TryConfigureForwardedHeaders(IServiceCollection services, IConfiguration configuration) + { + string[] trustedProxyValues = (configuration.GetSection("TrustedProxies").Get() ?? Array.Empty()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (trustedProxyValues.Length == 0) + { + return false; + } + + bool trustAnyProxy = trustedProxyValues.Contains("*"); + if (trustAnyProxy && trustedProxyValues.Length > 1) + { + throw new InvalidOperationException("TrustedProxies cannot combine '*' with IP addresses or CIDR networks."); + } + + var trustedProxyAddresses = new List(); + var trustedProxyNetworks = new List(); + + foreach (string value in trustedProxyValues.Where(value => value != "*")) + { + if (IPAddress.TryParse(value, out IPAddress? address)) + { + trustedProxyAddresses.Add(address); + } + else if (System.Net.IPNetwork.TryParse(value, out System.Net.IPNetwork network)) + { + trustedProxyNetworks.Add(network); + } + else + { + throw new InvalidOperationException($"TrustedProxies contains invalid IP address or CIDR network '{value}'."); + } + } + + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor + | ForwardedHeaders.XForwardedProto + | ForwardedHeaders.XForwardedHost + | ForwardedHeaders.XForwardedPrefix; + options.KnownProxies.Clear(); + options.KnownIPNetworks.Clear(); + + if (trustAnyProxy) + { + return; + } + + foreach (IPAddress trustedProxyAddress in trustedProxyAddresses) + { + options.KnownProxies.Add(trustedProxyAddress); + } + + foreach (System.Net.IPNetwork trustedProxyNetwork in trustedProxyNetworks) + { + options.KnownIPNetworks.Add(trustedProxyNetwork); + } + }); - string configKey = bSecure ? "ws_address" : "ws_address_insecure"; + return true; + } - string? ws_address = coreSettings.GetValue(configKey); + public static string BuildWebSocketUrl(HttpRequest request) + { + string webSocketScheme; + if (request.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + webSocketScheme = "wss"; + } + else if (request.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) + { + webSocketScheme = "ws"; + } + else + { + throw new InvalidOperationException($"Cannot build a WebSocket URL from request scheme '{request.Scheme}'."); + } - if (ws_address == null) + if (!request.Host.HasValue) { - throw new Exception(String.Format("{0} in Core section of config is null.", configKey)); + throw new InvalidOperationException("Cannot build a WebSocket URL because the request Host is empty."); } - return ws_address; + return UriHelper.BuildAbsolute(webSocketScheme, request.Host, request.PathBase, new PathString("/ws")); } public static async Task Main(string[] args) @@ -798,6 +902,10 @@ public static async Task Main(string[] args) g_Config = builder.Configuration; + // Process the original client IP, request scheme, and host only when + // one or more trusted reverse proxies are configured. + bool useForwardedHeaders = TryConfigureForwardedHeaders(builder.Services, builder.Configuration); + ShowLogo(); IConfigurationSection? sentrySettings = Program.g_Config.GetSection("Sentry"); @@ -891,19 +999,11 @@ public static async Task Main(string[] args) }); } + CorsPolicy corsPolicy = BuildCorsPolicy(builder.Configuration); + builder.Services.AddCors(options => { - options.AddDefaultPolicy(policy => - { - policy.WithOrigins( - "https://localhost:9000", - "http://localhost:9001", - "https://*.playgenerals.online" - ) - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials(); - }); + options.AddDefaultPolicy(corsPolicy); }); @@ -1105,35 +1205,6 @@ public static async Task Main(string[] args) } } - var kestrelSettings = Program.g_Config.GetSection("Kestrel"); - var endpointSettings = kestrelSettings.GetSection("Endpoints"); - var httpsSettings = endpointSettings.GetSection("HTTPS"); - string? serverURI = httpsSettings.GetValue("Url"); - - if (serverURI == null) - { - Console.WriteLine("FATAL ERROR: serverURI is not set in the config"); - Console.ReadKey(true); - return; - } - - // Parse the port number out of serverURI - int port = -1; - try - { - if (!string.IsNullOrEmpty(serverURI)) - { - var uri = new Uri(serverURI); - port = uri.Port; - } - } - catch - { - Console.WriteLine("ERROR: Failed to parse port from serverURI: " + serverURI); - } - - - // options builder.WebHost.ConfigureKestrel(options => { @@ -1162,6 +1233,12 @@ public static async Task Main(string[] args) var app = builder.Build(); ServiceLocator.Services = app.Services; + if (useForwardedHeaders) + { + // Must run before anything that consumes client IP or request scheme. + app.UseForwardedHeaders(); + } + if (bUseBuiltinRateLimiter) { app.UseRateLimiter(); @@ -1179,6 +1256,21 @@ public static async Task Main(string[] args) app.UseWebSockets(webSocketOptions); + // WebSockets do not use CORS, so apply the same origin policy to browser + // handshakes explicitly. Native clients may omit Origin. + app.Use(async (context, next) => + { + if (context.WebSockets.IsWebSocketRequest + && context.Request.Headers.TryGetValue("Origin", out var originHeaders) + && (originHeaders.Count != 1 || !corsPolicy.IsOriginAllowed(originHeaders[0]!))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + await next(context); + }); + // end websocket // Configure the HTTP request pipeline. @@ -1202,8 +1294,7 @@ public static async Task Main(string[] args) { app.UseHsts(); - // Websocket upgrades are exempt: ws_address_insecure is an intentional fallback for - // clients that can't do TLS, and redirecting the upgrade request would break it. + // WebSocket upgrades are exempt because redirecting an upgrade request breaks the handshake. app.UseWhen(context => !context.WebSockets.IsWebSocketRequest, branch => { branch.UseHttpsRedirection(); diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index 2243044..557aac0 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -19,6 +19,8 @@ } }, "AllowedHosts": "*", + "AllowedOrigins": [], + "TrustedProxies": [], "JwtSettings": { "Key": "TODO_CHANGE_ME", "Issuer": "YourIssuer", @@ -27,8 +29,6 @@ "ExpiresInMinutes_Refresh": 43200 }, "Core": { - "ws_address": "wss://127.0.0.1:9000/ws", - "ws_address_insecure": "ws://127.0.0.1:9000/ws", "use_os_cert_store": true, "cert_pem_path": null, "cert_key_path": null,