diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordController.cs index e281cc1d65fe..0cb407ca9aad 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/ResetPasswordController.cs @@ -49,9 +49,13 @@ public async Task RequestPasswordReset(CancellationToken cancella // If this feature is switched off in configuration, the UI will be amended to not make the request to reset password available. // So this is just a server-side secondary check. + // ApplicationUrlNotConfigured is also surfaced since it is a server-wide configuration issue, not user-specific. // Regardless of other status values, it will just return Ok, so you can't use this endpoint to determine whether the email exists in the system. - return result.Result == UserOperationStatus.CannotPasswordReset - ? BadRequest() - : Ok(); + return result.Result switch + { + UserOperationStatus.CannotPasswordReset => BadRequest(), + UserOperationStatus.ApplicationUrlNotConfigured => UserOperationStatusResult(result.Result), + _ => Ok(), + }; } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs index 0e500ae60ad1..f6cea30a91d0 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/SecurityControllerBase.cs @@ -33,6 +33,10 @@ protected IActionResult UserOperationStatusResult(UserOperationStatus status, Er .WithTitle("Unknown failure") .WithDetail(errorMessageResult?.Error?.ErrorMessage ?? "The error was unknown") .Build()), + UserOperationStatus.ApplicationUrlNotConfigured => BadRequest(problemDetailsBuilder + .WithTitle("Application URL not configured") + .WithDetail("The application URL is not configured. Set Umbraco:CMS:WebRouting:UmbracoApplicationUrl in configuration, or change Umbraco:CMS:WebRouting:ApplicationUrlDetection to 'FirstRequest' or 'EveryRequest'.") + .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown user operation status.") .Build()), diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs index dc5d40848371..aa8b544cc9e4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs @@ -140,6 +140,10 @@ protected IActionResult UserOperationStatusResult(UserOperationStatus status, Er .WithDetail("The target user type does not support this operation.") .Build()), UserOperationStatus.Forbidden => Forbidden(), + UserOperationStatus.ApplicationUrlNotConfigured => BadRequest(problemDetailsBuilder + .WithTitle("Application URL not configured") + .WithDetail("The application URL is not configured. Set Umbraco:CMS:WebRouting:UmbracoApplicationUrl in configuration, or change Umbraco:CMS:WebRouting:ApplicationUrlDetection to 'FirstRequest' or 'EveryRequest'.") + .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown user operation status.") .Build()), diff --git a/src/Umbraco.Cms.Api.Management/Security/ForgotPasswordUriProvider.cs b/src/Umbraco.Cms.Api.Management/Security/ForgotPasswordUriProvider.cs index e0f88a6caf81..7d71500a2ce6 100644 --- a/src/Umbraco.Cms.Api.Management/Security/ForgotPasswordUriProvider.cs +++ b/src/Umbraco.Cms.Api.Management/Security/ForgotPasswordUriProvider.cs @@ -1,8 +1,5 @@ using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Options; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; @@ -16,7 +13,6 @@ namespace Umbraco.Cms.Api.Management.Security; /// public class ForgotPasswordUriProvider : IForgotPasswordUriProvider { - private readonly ICoreBackOfficeUserManager _userManager; private readonly IHostingEnvironment _hostingEnvironment; private readonly IHttpContextAccessor _httpContextAccessor; @@ -37,29 +33,37 @@ public ForgotPasswordUriProvider( _httpContextAccessor = httpContextAccessor; } + /// public async Task> CreateForgotPasswordUriAsync(IUser user) { - Attempt tokenAttempt = await _userManager.GeneratePasswordResetTokenAsync(user); + if (_httpContextAccessor.HttpContext is null) + { + throw new NotSupportedException("Needs a HttpContext"); + } - if (tokenAttempt.Success is false) + Uri? appUrl = _hostingEnvironment.ApplicationMainUrl; + if (appUrl is null) { - return Attempt.FailWithStatus(tokenAttempt.Status, new Uri(string.Empty)); + return Attempt.FailWithStatus(UserOperationStatus.ApplicationUrlNotConfigured, default!); } - HttpRequest? request = _httpContextAccessor.HttpContext?.Request; - if (request is null) + Attempt tokenAttempt = await _userManager.GeneratePasswordResetTokenAsync(user); + + if (tokenAttempt.Success is false) { - throw new NotSupportedException("Needs a HttpContext"); + return Attempt.FailWithStatus(tokenAttempt.Status, default!); } - var uriBuilder = new UriBuilder(_hostingEnvironment.ApplicationMainUrl); - uriBuilder.Path = BackOfficeLoginController.LoginPath; - uriBuilder.Query = QueryString.Create(new KeyValuePair[] + var uriBuilder = new UriBuilder(appUrl) { - new ("flow", "reset-password"), - new ("userId", user.Key.ToString()), - new ("resetCode", tokenAttempt.Result.ToUrlBase64()), - }).ToUriComponent(); + Path = BackOfficeLoginController.LoginPath, + Query = QueryString.Create(new KeyValuePair[] + { + new("flow", "reset-password"), + new("userId", user.Key.ToString()), + new("resetCode", tokenAttempt.Result.ToUrlBase64()), + }).ToUriComponent(), + }; return Attempt.SucceedWithStatus(UserOperationStatus.Success, uriBuilder.Uri); } diff --git a/src/Umbraco.Cms.Api.Management/Security/InviteUriProvider.cs b/src/Umbraco.Cms.Api.Management/Security/InviteUriProvider.cs index dc1e9637d9d0..0a890427441a 100644 --- a/src/Umbraco.Cms.Api.Management/Security/InviteUriProvider.cs +++ b/src/Umbraco.Cms.Api.Management/Security/InviteUriProvider.cs @@ -28,7 +28,6 @@ public InviteUriProvider( IHttpContextAccessor httpContextAccessor, IHostingEnvironment hostingEnvironment) { - _userManager = userManager; _httpContextAccessor = httpContextAccessor; _hostingEnvironment = hostingEnvironment; @@ -43,27 +42,34 @@ public InviteUriProvider( /// public async Task> CreateInviteUriAsync(IUser invitee) { - Attempt tokenAttempt = await _userManager.GenerateEmailConfirmationTokenAsync(invitee); + if (_httpContextAccessor.HttpContext is null) + { + throw new NotSupportedException("Needs a HttpContext"); + } - if (tokenAttempt.Success is false) + Uri? appUrl = _hostingEnvironment.ApplicationMainUrl; + if (appUrl is null) { - return Attempt.FailWithStatus(tokenAttempt.Status, new Uri(string.Empty)); + return Attempt.FailWithStatus(UserOperationStatus.ApplicationUrlNotConfigured, default!); } - HttpRequest? request = _httpContextAccessor.HttpContext?.Request; - if (request is null) + Attempt tokenAttempt = await _userManager.GenerateEmailConfirmationTokenAsync(invitee); + + if (tokenAttempt.Success is false) { - throw new NotSupportedException("Needs a HttpContext"); + return Attempt.FailWithStatus(tokenAttempt.Status, default!); } - var uriBuilder = new UriBuilder(_hostingEnvironment.ApplicationMainUrl); - uriBuilder.Path = BackOfficeLoginController.LoginPath; - uriBuilder.Query = QueryString.Create(new KeyValuePair[] + var uriBuilder = new UriBuilder(appUrl) { - new ("flow", "invite-user"), - new ("userId", invitee.Key.ToString()), - new ("inviteCode", tokenAttempt.Result.ToUrlBase64()), - }).ToUriComponent(); + Path = BackOfficeLoginController.LoginPath, + Query = QueryString.Create(new KeyValuePair[] + { + new ("flow", "invite-user"), + new ("userId", invitee.Key.ToString()), + new ("inviteCode", tokenAttempt.Result.ToUrlBase64()), + }).ToUriComponent() + }; return Attempt.SucceedWithStatus(UserOperationStatus.Success, uriBuilder.Uri); } diff --git a/src/Umbraco.Core/Configuration/Models/ApplicationUrlDetection.cs b/src/Umbraco.Core/Configuration/Models/ApplicationUrlDetection.cs new file mode 100644 index 000000000000..3649cd6c9e86 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/ApplicationUrlDetection.cs @@ -0,0 +1,27 @@ +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Specifies how the application main URL is detected from incoming HTTP requests. +/// +public enum ApplicationUrlDetection +{ + /// + /// No auto-detection. The application URL must be explicitly configured + /// via . + /// Operations that require a URL (invitations, password resets) will fail + /// if no explicit URL is configured. + /// + None, + + /// + /// The URL is set from the first HTTP request and then locked. + /// Subsequent requests with different host headers are ignored. + /// + FirstRequest, + + /// + /// The URL is updated from every new incoming HTTP request (legacy behavior). + /// This is vulnerable to host header poisoning. + /// + EveryRequest, +} diff --git a/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs b/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs index 656a27fe9b86..6767d9ab87a7 100644 --- a/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs @@ -62,6 +62,11 @@ public class WebRoutingSettings /// internal const bool StaticUseStrictDomainMatching = false; + /// + /// The default value for application URL detection mode. + /// + internal const ApplicationUrlDetection StaticApplicationUrlDetection = ApplicationUrlDetection.None; + /// /// Gets or sets a value indicating whether to check if any routed endpoints match a front-end request before /// the Umbraco dynamic router tries to map the request to an Umbraco content item. @@ -123,6 +128,13 @@ public class WebRoutingSettings /// public string UmbracoApplicationUrl { get; set; } = null!; + /// + /// Gets or sets a value controlling how the application main URL is auto-detected + /// from incoming HTTP requests (). + /// + [DefaultValue(StaticApplicationUrlDetection)] + public ApplicationUrlDetection ApplicationUrlDetection { get; set; } = StaticApplicationUrlDetection; + /// /// Gets or sets a value indicating whether strict domain matching is used when finding content to match the request. /// diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 5acfebebc39c..7edf557333cb 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -437,6 +437,7 @@ Your website's SSL certificate has expired. Your website's SSL certificate is expiring in %0% days. Error pinging the URL %0% - '%1%' + The application URL is not available. Configure Umbraco:CMS:WebRouting:UmbracoApplicationUrl or change ApplicationUrlDetection to enable this check. You are currently %0% viewing the site using the HTTPS scheme. The appSetting 'Umbraco:CMS:Global:UseHttps' is set to 'false' in your appSettings.json file. Once you access this site using the HTTPS scheme, that should be set to 'true'. diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index fdb6b14f5929..1ba9e99106f5 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -426,6 +426,7 @@ Your website's SSL certificate has expired. Your website's SSL certificate is expiring in %0% days. Error pinging the URL %0% - '%1%' + The application URL is not available. Configure Umbraco:CMS:WebRouting:UmbracoApplicationUrl or change ApplicationUrlDetection to enable this check. You are currently %0% viewing the site using the HTTPS scheme. The appSetting 'Umbraco:CMS:Global:UseHttps' is set to 'false' in your appSettings.json file. Once you access this site using the HTTPS scheme, that should be set to 'true'. diff --git a/src/Umbraco.Core/HealthChecks/Checks/Security/BaseHttpHeaderCheck.cs b/src/Umbraco.Core/HealthChecks/Checks/Security/BaseHttpHeaderCheck.cs index c9467a408a39..41fc3c5860f3 100644 --- a/src/Umbraco.Core/HealthChecks/Checks/Security/BaseHttpHeaderCheck.cs +++ b/src/Umbraco.Core/HealthChecks/Checks/Security/BaseHttpHeaderCheck.cs @@ -79,6 +79,16 @@ protected async Task CheckForHeader() // Access the site home page and check for the click-jack protection header or meta tag var url = _hostingEnvironment.ApplicationMainUrl?.GetLeftPart(UriPartial.Authority); + if (url is null) + { + return new HealthCheckStatus( + LocalizedTextService.Localize("healthcheck", "httpsCheckNoApplicationUrl")) + { + ResultType = StatusResultType.Info, + ReadMoreLink = ReadMoreLink, + }; + } + try { using HttpResponseMessage response = await HttpClient.GetAsync(url); diff --git a/src/Umbraco.Core/HealthChecks/Checks/Security/ExcessiveHeadersCheck.cs b/src/Umbraco.Core/HealthChecks/Checks/Security/ExcessiveHeadersCheck.cs index 37d46d00e3e8..52141bec6146 100644 --- a/src/Umbraco.Core/HealthChecks/Checks/Security/ExcessiveHeadersCheck.cs +++ b/src/Umbraco.Core/HealthChecks/Checks/Security/ExcessiveHeadersCheck.cs @@ -46,6 +46,16 @@ private async Task CheckForHeaders() var success = false; var url = _hostingEnvironment.ApplicationMainUrl?.GetLeftPart(UriPartial.Authority); + if (url is null) + { + return new HealthCheckStatus( + _textService.Localize("healthcheck", "httpsCheckNoApplicationUrl")) + { + ResultType = StatusResultType.Info, + ReadMoreLink = Constants.HealthChecks.DocumentationLinks.Security.ExcessiveHeadersCheck, + }; + } + // Access the site home page and check for the headers using var request = new HttpRequestMessage(HttpMethod.Head, url); try diff --git a/src/Umbraco.Core/HealthChecks/Checks/Security/HttpsCheck.cs b/src/Umbraco.Core/HealthChecks/Checks/Security/HttpsCheck.cs index c13ade6ea1db..444243b0c461 100644 --- a/src/Umbraco.Core/HealthChecks/Checks/Security/HttpsCheck.cs +++ b/src/Umbraco.Core/HealthChecks/Checks/Security/HttpsCheck.cs @@ -74,8 +74,27 @@ private static bool ServerCertificateCustomValidation( return sslErrors == SslPolicyErrors.None; } + private HealthCheckStatus? CheckApplicationUrlAvailable() + { + if (_hostingEnvironment.ApplicationMainUrl is not null) + { + return null; + } + + return new HealthCheckStatus( + _textService.Localize("healthcheck", "httpsCheckNoApplicationUrl")) + { + ResultType = StatusResultType.Info, + }; + } + private async Task CheckForValidCertificate() { + if (CheckApplicationUrlAvailable() is HealthCheckStatus unavailable) + { + return unavailable; + } + string message; StatusResultType result; @@ -154,6 +173,11 @@ private async Task CheckForValidCertificate() private Task CheckIfCurrentSchemeIsHttps() { + if (CheckApplicationUrlAvailable() is HealthCheckStatus unavailable) + { + return Task.FromResult(unavailable); + } + Uri uri = _hostingEnvironment.ApplicationMainUrl; var success = uri.Scheme == Uri.UriSchemeHttps; @@ -169,6 +193,11 @@ private Task CheckIfCurrentSchemeIsHttps() private Task CheckHttpsConfigurationSetting() { + if (CheckApplicationUrlAvailable() is HealthCheckStatus unavailable) + { + return Task.FromResult(unavailable); + } + var httpsSettingEnabled = _globalSettings.CurrentValue.UseHttps; Uri uri = _hostingEnvironment.ApplicationMainUrl; diff --git a/src/Umbraco.Core/Hosting/IHostingEnvironment.cs b/src/Umbraco.Core/Hosting/IHostingEnvironment.cs index f2b187b0443d..ad507255af65 100644 --- a/src/Umbraco.Core/Hosting/IHostingEnvironment.cs +++ b/src/Umbraco.Core/Hosting/IHostingEnvironment.cs @@ -83,6 +83,7 @@ public interface IHostingEnvironment /// /// Gets the main application url. /// + // TODO (V18): Change to Uri? to reflect that this can be null when ApplicationUrlDetection is None and no explicit URL is configured. Uri ApplicationMainUrl { get; } /// diff --git a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs index f2aeab772fd3..932be7452e6e 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs @@ -189,4 +189,11 @@ public enum UserOperationStatus /// The operation failed because the username is invalid. /// InvalidUserName, + + /// + /// The operation failed because the application URL is not configured. + /// Set Umbraco:CMS:WebRouting:UmbracoApplicationUrl or change + /// ApplicationUrlDetection to FirstRequest or EveryRequest. + /// + ApplicationUrlNotConfigured, } diff --git a/src/Umbraco.Web.Common/AspNetCore/ApplicationUrlConfigurationNotificationHandler.cs b/src/Umbraco.Web.Common/AspNetCore/ApplicationUrlConfigurationNotificationHandler.cs new file mode 100644 index 000000000000..5ba8e86d8e51 --- /dev/null +++ b/src/Umbraco.Web.Common/AspNetCore/ApplicationUrlConfigurationNotificationHandler.cs @@ -0,0 +1,62 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; + +namespace Umbraco.Cms.Web.Common.AspNetCore; + +/// +/// Logs the application URL configuration status at startup, providing guidance +/// when the URL is not configured and auto-detection is disabled. +/// +internal sealed class ApplicationUrlConfigurationNotificationHandler + : INotificationHandler +{ + private readonly ILogger _logger; + private readonly IOptionsMonitor _webRoutingSettings; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The web routing settings monitor. + public ApplicationUrlConfigurationNotificationHandler( + ILogger logger, + IOptionsMonitor webRoutingSettings) + { + _logger = logger; + _webRoutingSettings = webRoutingSettings; + } + + /// + public void Handle(UmbracoApplicationStartedNotification notification) + { + WebRoutingSettings settings = _webRoutingSettings.CurrentValue; + + if (settings.UmbracoApplicationUrl is not null) + { + _logger.LogInformation( + "Application URL configured as {ApplicationMainUrl}.", + settings.UmbracoApplicationUrl); + return; + } + + if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None) + { + _logger.LogWarning( + "Application URL auto-detection is disabled and no explicit URL is configured. " + + "Email links (invitations, password resets) will not be available. " + + "Set Umbraco:CMS:WebRouting:UmbracoApplicationUrl in configuration, " + + "or change Umbraco:CMS:WebRouting:ApplicationUrlDetection to 'FirstRequest' or 'EveryRequest'."); + return; + } + + _logger.LogInformation( + "Application URL auto-detection is enabled ({DetectionMode}). ", + settings.ApplicationUrlDetection); + } +} diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs index 9a57147bcc05..4c0600be41d3 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.DataProtection.Infrastructure; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Collections; using Umbraco.Cms.Core.Configuration; @@ -12,6 +11,10 @@ namespace Umbraco.Cms.Web.Common.AspNetCore; +/// +/// ASP.NET Core implementation of that provides +/// hosting information such as the application URL, physical paths, site name, and debug mode. +/// public class AspNetCoreHostingEnvironment : IHostingEnvironment { private readonly IApplicationDiscriminator? _applicationDiscriminator; @@ -22,9 +25,14 @@ public class AspNetCoreHostingEnvironment : IHostingEnvironment private readonly UrlMode _urlProviderMode; - private string? _applicationId; - private string? _localTempPath; - + /// + /// Initializes a new instance of the class + /// with an for unique application identification. + /// + /// The hosting settings monitor. + /// The web routing settings monitor. + /// The ASP.NET Core web host environment. + /// The application discriminator used for generating a unique application identifier. public AspNetCoreHostingEnvironment( IOptionsMonitor hostingSettings, IOptionsMonitor webRoutingSettings, @@ -33,6 +41,12 @@ public AspNetCoreHostingEnvironment( : this(hostingSettings, webRoutingSettings, webHostEnvironment) => _applicationDiscriminator = applicationDiscriminator; + /// + /// Initializes a new instance of the class. + /// + /// The hosting settings monitor. + /// The web routing settings monitor. + /// The ASP.NET Core web host environment. public AspNetCoreHostingEnvironment( IOptionsMonitor hostingSettings, IOptionsMonitor webRoutingSettings, @@ -64,8 +78,14 @@ public AspNetCoreHostingEnvironment( /// public bool IsHosted { get; } = true; + private Uri? _applicationMainUrl; + /// - public Uri ApplicationMainUrl { get; private set; } = null!; + public Uri ApplicationMainUrl + { + get => _applicationMainUrl!; + private set => _applicationMainUrl = value; + } /// public string? SiteName { get; private set; } @@ -75,35 +95,35 @@ public string ApplicationId { get { - if (_applicationId != null) + if (field != null) { - return _applicationId; + return field; } - _applicationId = _applicationDiscriminator?.GetApplicationId() ?? - _webHostEnvironment.GetTemporaryApplicationId(); + field = _applicationDiscriminator?.GetApplicationId() ?? _webHostEnvironment.GetTemporaryApplicationId(); - return _applicationId; + return field; } } /// public string ApplicationPhysicalPath { get; } - // TODO how to find this, This is a server thing, not application thing. + /// public string ApplicationVirtualPath => _hostingSettings.CurrentValue.ApplicationVirtualPath?.EnsureStartsWith('/') ?? "/"; /// public bool IsDebugMode { get; private set; } + /// public string LocalTempPath { get { - if (_localTempPath != null) + if (field != null) { - return _localTempPath; + return field; } switch (_hostingSettings.CurrentValue.LocalTempStorageLocation) @@ -123,11 +143,11 @@ public string LocalTempPath var hash = hashString.GenerateHash(); var siteTemp = Path.Combine(Path.GetTempPath(), "UmbracoData", hash); - return _localTempPath = siteTemp; + return field = siteTemp; default: - return _localTempPath = MapPathContentRoot(Core.Constants.SystemDirectories.TempData); + return field = MapPathContentRoot(Core.Constants.SystemDirectories.TempData); } } } @@ -163,32 +183,42 @@ public string ToAbsolute(string virtualPath) return fullPath; } + /// public void EnsureApplicationMainUrl(Uri? currentApplicationUrl) { - // TODO: This causes problems with site swap on azure because azure pre-warms a site by calling into `localhost` and when it does that - // it changes the URL to `localhost:80` which actually doesn't work for pinging itself, it only works internally in Azure. The ironic part - // about this is that this is here specifically for the slot swap scenario https://issues.umbraco.org/issue/U4-10626 - - // see U4-10626 - in some cases we want to reset the application url - // (this is a simplified version of what was in 7.x) - // note: should this be optional? is it expensive? if (currentApplicationUrl is null) { return; } + // Explicit configuration always takes precedence. if (_webRoutingSettings.CurrentValue.UmbracoApplicationUrl is not null) { return; } - var change = !_applicationUrls.Contains(currentApplicationUrl); - if (change) + switch (_webRoutingSettings.CurrentValue.ApplicationUrlDetection) { - if (_applicationUrls.TryAdd(currentApplicationUrl)) - { - ApplicationMainUrl = currentApplicationUrl; - } + case ApplicationUrlDetection.None: + return; + + case ApplicationUrlDetection.FirstRequest: + // Atomic: only the first thread to arrive sets the URL. + // Subsequent calls (even concurrent ones with different hosts) are no-ops. + Interlocked.CompareExchange(ref _applicationMainUrl, currentApplicationUrl, null); + break; + + case ApplicationUrlDetection.EveryRequest: + var change = _applicationUrls.Contains(currentApplicationUrl) is false; + if (change) + { + if (_applicationUrls.TryAdd(currentApplicationUrl)) + { + ApplicationMainUrl = currentApplicationUrl; + } + } + + break; } } diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 3c696c264170..7f4a99abaec1 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -296,6 +296,7 @@ public static IUmbracoBuilder AddWebComponents(this IUmbracoBuilder builder) // AspNetCore specific services builder.Services.AddUnique(); builder.AddNotificationHandler(); + builder.AddNotificationHandler(); // Password hasher builder.Services.AddUnique(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Security/UriProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Security/UriProviderTests.cs new file mode 100644 index 000000000000..c716733a8fd9 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Security/UriProviderTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.AspNetCore.Http; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Security; + +[TestFixture] +public class UriProviderTests +{ + private Mock _userManager = null!; + private Mock _hostingEnvironment = null!; + private Mock _httpContextAccessor = null!; + private Mock _user = null!; + + [SetUp] + public void SetUp() + { + _userManager = new Mock(); + _hostingEnvironment = new Mock(); + _httpContextAccessor = new Mock(); + _httpContextAccessor.Setup(a => a.HttpContext).Returns(new DefaultHttpContext()); + _user = new Mock(); + _user.Setup(u => u.Key).Returns(Guid.NewGuid()); + } + + [Test] + public async Task ForgotPasswordUri_WithApplicationMainUrl_ReturnsAbsoluteUri() + { + var appUrl = new Uri("https://my-site.com"); + _hostingEnvironment.Setup(h => h.ApplicationMainUrl).Returns(appUrl); + _userManager + .Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny())) + .ReturnsAsync(Attempt.SucceedWithStatus(UserOperationStatus.Success, "test-token")); + + var sut = new ForgotPasswordUriProvider( + _userManager.Object, + _hostingEnvironment.Object, + _httpContextAccessor.Object); + + Attempt result = await sut.CreateForgotPasswordUriAsync(_user.Object); + + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Result.IsAbsoluteUri); + Assert.AreEqual("https", result.Result.Scheme); + Assert.AreEqual("my-site.com", result.Result.Host); + Assert.That(result.Result.AbsolutePath, Does.StartWith("/umbraco/login")); + Assert.That(result.Result.Query, Does.Contain("flow=reset-password")); + } + + [Test] + public async Task ForgotPasswordUri_WithoutApplicationMainUrl_FailsWithApplicationUrlNotConfigured() + { + _hostingEnvironment.Setup(h => h.ApplicationMainUrl).Returns((Uri?)null); + + var sut = new ForgotPasswordUriProvider( + _userManager.Object, + _hostingEnvironment.Object, + _httpContextAccessor.Object); + + Attempt result = await sut.CreateForgotPasswordUriAsync(_user.Object); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.ApplicationUrlNotConfigured, result.Status); + _userManager.Verify(m => m.GeneratePasswordResetTokenAsync(It.IsAny()), Times.Never()); + } + + [Test] + public async Task InviteUri_WithApplicationMainUrl_ReturnsAbsoluteUri() + { + var appUrl = new Uri("https://my-site.com"); + _hostingEnvironment.Setup(h => h.ApplicationMainUrl).Returns(appUrl); + _userManager + .Setup(m => m.GenerateEmailConfirmationTokenAsync(It.IsAny())) + .ReturnsAsync(Attempt.SucceedWithStatus(UserOperationStatus.Success, "test-token")); + + var sut = new InviteUriProvider( + _userManager.Object, + _httpContextAccessor.Object, + _hostingEnvironment.Object); + + Attempt result = await sut.CreateInviteUriAsync(_user.Object); + + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Result.IsAbsoluteUri); + Assert.AreEqual("https", result.Result.Scheme); + Assert.AreEqual("my-site.com", result.Result.Host); + Assert.That(result.Result.AbsolutePath, Does.StartWith("/umbraco/login")); + Assert.That(result.Result.Query, Does.Contain("flow=invite-user")); + } + + [Test] + public async Task InviteUri_WithoutApplicationMainUrl_FailsWithApplicationUrlNotConfigured() + { + _hostingEnvironment.Setup(h => h.ApplicationMainUrl).Returns((Uri?)null); + + var sut = new InviteUriProvider( + _userManager.Object, + _httpContextAccessor.Object, + _hostingEnvironment.Object); + + Attempt result = await sut.CreateInviteUriAsync(_user.Object); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.ApplicationUrlNotConfigured, result.Status); + _userManager.Verify(m => m.GenerateEmailConfirmationTokenAsync(It.IsAny()), Times.Never()); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/ApplicationUrlConfigurationNotificationHandlerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/ApplicationUrlConfigurationNotificationHandlerTests.cs new file mode 100644 index 000000000000..9ffc473ed524 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/ApplicationUrlConfigurationNotificationHandlerTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Web.Common.AspNetCore; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website; + +[TestFixture] +public class ApplicationUrlConfigurationNotificationHandlerTests +{ + private FakeLogger _logger = null!; + + [SetUp] + public void SetUp() => _logger = new FakeLogger(); + + [Test] + public void Handle_WithExplicitUrl_LogsInformationWithConfiguredUrl() + { + var settings = new WebRoutingSettings { UmbracoApplicationUrl = "https://my-site.com" }; + var sut = CreateHandler(settings); + + sut.Handle(new UmbracoApplicationStartedNotification(false)); + + Assert.That(_logger.LogEntries, Has.Exactly(1).Matches( + e => e.Level == LogLevel.Information && e.Message.Contains("configured"))); + } + + [Test] + public void Handle_NoneMode_NoExplicitUrl_LogsWarning() + { + var settings = new WebRoutingSettings { ApplicationUrlDetection = ApplicationUrlDetection.None }; + var sut = CreateHandler(settings); + + sut.Handle(new UmbracoApplicationStartedNotification(false)); + + Assert.That(_logger.LogEntries, Has.Exactly(1).Matches( + e => e.Level == LogLevel.Warning && e.Message.Contains("auto-detection is disabled"))); + } + + [Test] + public void Handle_FirstRequestMode_NoExplicitUrl_LogsAutoDetectionEnabled() + { + var settings = new WebRoutingSettings { ApplicationUrlDetection = ApplicationUrlDetection.FirstRequest }; + var sut = CreateHandler(settings); + + sut.Handle(new UmbracoApplicationStartedNotification(false)); + + Assert.That(_logger.LogEntries, Has.Exactly(1).Matches( + e => e.Level == LogLevel.Information && e.Message.Contains("auto-detection is enabled"))); + } + + [Test] + public void Handle_EveryRequestMode_NoExplicitUrl_LogsAutoDetectionEnabled() + { + var settings = new WebRoutingSettings { ApplicationUrlDetection = ApplicationUrlDetection.EveryRequest }; + var sut = CreateHandler(settings); + + sut.Handle(new UmbracoApplicationStartedNotification(false)); + + Assert.That(_logger.LogEntries, Has.Exactly(1).Matches( + e => e.Level == LogLevel.Information && e.Message.Contains("auto-detection is enabled"))); + } + + [Test] + public void Handle_WithExplicitUrl_DoesNotLogWarning() + { + var settings = new WebRoutingSettings + { + UmbracoApplicationUrl = "https://my-site.com", + ApplicationUrlDetection = ApplicationUrlDetection.None, + }; + var sut = CreateHandler(settings); + + sut.Handle(new UmbracoApplicationStartedNotification(false)); + + Assert.That(_logger.LogEntries, Has.None.Matches( + e => e.Level == LogLevel.Warning)); + } + + private ApplicationUrlConfigurationNotificationHandler CreateHandler(WebRoutingSettings settings) + { + var optionsMonitor = Mock.Of>( + m => m.CurrentValue == settings); + return new ApplicationUrlConfigurationNotificationHandler(_logger, optionsMonitor); + } + + /// + /// Simple logger that captures log entries for assertion, avoiding Moq proxy issues + /// with ILogger<T> of internal types. + /// + private sealed class FakeLogger : ILogger + { + public List LogEntries { get; } = []; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => LogEntries.Add(new LogEntry(logLevel, formatter(state, exception))); + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public record LogEntry(LogLevel Level, string Message); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/AspNetCoreHostingEnvironmentTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/AspNetCoreHostingEnvironmentTests.cs index 5fac76d42487..745d2f2ff3a9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/AspNetCoreHostingEnvironmentTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/AspNetCoreHostingEnvironmentTests.cs @@ -1,7 +1,11 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Options; +using Moq; using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.UnitTests.AutoFixture; using Umbraco.Cms.Web.Common.AspNetCore; @@ -36,12 +40,224 @@ public void EnsurePathIsApplicationRootPrefixed() Assert.AreEqual("~/Views/Template.cshtml", PathUtility.EnsurePathIsApplicationRootPrefixed("~/Views/Template.cshtml")); } - [AutoMoqData] [Test] - public void EnsureApplicationMainUrl(AspNetCoreHostingEnvironment sut) + public void EnsureApplicationMainUrl() { + var sut = CreateWithDefaultConfig(ApplicationUrlDetection.FirstRequest); var url = new Uri("http://localhost:5000"); sut.EnsureApplicationMainUrl(url); Assert.AreEqual(sut.ApplicationMainUrl, url); } + + /// + /// Creates an AspNetCoreHostingEnvironment with UmbracoApplicationUrl = null, + /// simulating the default configuration where no explicit URL is configured. + /// + private static AspNetCoreHostingEnvironment CreateWithDefaultConfig( + ApplicationUrlDetection detection = ApplicationUrlDetection.FirstRequest) + { + var hostingSettings = new HostingSettings(); + var webRoutingSettings = new WebRoutingSettings + { + ApplicationUrlDetection = detection, + }; + + var hostingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == hostingSettings); + var webRoutingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == webRoutingSettings); + + var webHostEnvironment = new Mock(); + webHostEnvironment.Setup(e => e.ContentRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.WebRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.ApplicationName).Returns("TestApp"); + + return new AspNetCoreHostingEnvironment( + hostingSettingsMonitor, + webRoutingSettingsMonitor, + webHostEnvironment.Object); + } + + [Test] + public void EnsureApplicationMainUrl_LocksAfterFirstUrl() + { + var sut = CreateWithDefaultConfig(ApplicationUrlDetection.FirstRequest); + + var legitimateUrl = new Uri("https://legit-site.com"); + var attackerUrl = new Uri("https://non-configured-site.com"); + + // Step 1: Normal traffic sets the URL + sut.EnsureApplicationMainUrl(legitimateUrl); + Assert.AreEqual(legitimateUrl, sut.ApplicationMainUrl, "Initial legitimate URL should be set"); + + // Step 2: Attacker sends request with forged Host header — must be ignored + sut.EnsureApplicationMainUrl(attackerUrl); + Assert.AreEqual(legitimateUrl, sut.ApplicationMainUrl, "Attacker URL must not overwrite the legitimate URL"); + + // Step 3: Legitimate traffic continues — URL remains stable + sut.EnsureApplicationMainUrl(legitimateUrl); + Assert.AreEqual(legitimateUrl, sut.ApplicationMainUrl, "Legitimate URL is retained"); + } + + [Test] + public void EnsureApplicationMainUrl_IgnoresSubsequentUrls() + { + var sut = CreateWithDefaultConfig(ApplicationUrlDetection.FirstRequest); + + var legitimateUrl = new Uri("https://legit-site.com"); + + sut.EnsureApplicationMainUrl(legitimateUrl); + sut.EnsureApplicationMainUrl(new Uri("https://evil1.com")); + sut.EnsureApplicationMainUrl(new Uri("https://evil2.com")); + + Assert.AreEqual(legitimateUrl, sut.ApplicationMainUrl, "First URL is locked, all subsequent URLs are ignored"); + } + + [Test] + public void EnsureApplicationMainUrl_NullDoesNotLock() + { + var sut = CreateWithDefaultConfig(ApplicationUrlDetection.FirstRequest); + + sut.EnsureApplicationMainUrl(null); + + var url = new Uri("https://legit-site.com"); + sut.EnsureApplicationMainUrl(url); + Assert.AreEqual(url, sut.ApplicationMainUrl); + } + + [Test] + public void EnsureApplicationMainUrl_NoneMode_NeverSetsUrl() + { + var sut = CreateWithDefaultConfig(ApplicationUrlDetection.None); + + sut.EnsureApplicationMainUrl(new Uri("https://legit-site.com")); + + Assert.IsNull(sut.ApplicationMainUrl); + } + + [Test] + public void EnsureApplicationMainUrl_NoneMode_ExplicitConfigStillWorks() + { + var webRoutingSettings = new WebRoutingSettings + { + UmbracoApplicationUrl = "https://configured-site.com", + ApplicationUrlDetection = ApplicationUrlDetection.None, + }; + + var hostingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == new HostingSettings()); + var webRoutingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == webRoutingSettings); + + var webHostEnvironment = new Mock(); + webHostEnvironment.Setup(e => e.ContentRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.WebRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.ApplicationName).Returns("TestApp"); + + var sut = new AspNetCoreHostingEnvironment( + hostingSettingsMonitor, + webRoutingSettingsMonitor, + webHostEnvironment.Object); + + // Explicit config is set in the constructor, not via auto-detection + Assert.AreEqual(new Uri("https://configured-site.com"), sut.ApplicationMainUrl); + } + + [Test] + public void EnsureApplicationMainUrl_EveryRequest_OverwritesOnNewUrl() + { + var sut = CreateWithDefaultConfig(ApplicationUrlDetection.EveryRequest); + + var url1 = new Uri("https://site-a.com"); + var url2 = new Uri("https://site-b.com"); + + sut.EnsureApplicationMainUrl(url1); + Assert.AreEqual(url1, sut.ApplicationMainUrl); + + sut.EnsureApplicationMainUrl(url2); + Assert.AreEqual(url2, sut.ApplicationMainUrl, "New URL should overwrite in EveryRequest mode"); + } + + [Test] + public void EnsureApplicationMainUrl_EveryRequest_SameUrlNoOp() + { + var sut = CreateWithDefaultConfig(ApplicationUrlDetection.EveryRequest); + + var url = new Uri("https://site-a.com"); + sut.EnsureApplicationMainUrl(url); + sut.EnsureApplicationMainUrl(url); + + Assert.AreEqual(url, sut.ApplicationMainUrl, "Repeated same URL is a no-op"); + } + + [Test] + public void EnsureApplicationMainUrl_EveryRequest_ExplicitConfigTakesPrecedence() + { + var webRoutingSettings = new WebRoutingSettings + { + UmbracoApplicationUrl = "https://configured-site.com", + ApplicationUrlDetection = ApplicationUrlDetection.EveryRequest, + }; + + var hostingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == new HostingSettings()); + var webRoutingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == webRoutingSettings); + + var webHostEnvironment = new Mock(); + webHostEnvironment.Setup(e => e.ContentRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.WebRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.ApplicationName).Returns("TestApp"); + + var sut = new AspNetCoreHostingEnvironment( + hostingSettingsMonitor, + webRoutingSettingsMonitor, + webHostEnvironment.Object); + + Assert.AreEqual(new Uri("https://configured-site.com"), sut.ApplicationMainUrl); + + // Attempt to overwrite via auto-detection + sut.EnsureApplicationMainUrl(new Uri("https://non-configured-site.com")); + + Assert.AreEqual( + new Uri("https://configured-site.com"), + sut.ApplicationMainUrl, + "Explicit config prevents auto-detection overwrite"); + } + + [Test] + public void EnsureApplicationMainUrl_WithExplicitConfig_IgnoresHostHeader() + { + // When UmbracoApplicationUrl IS configured, poisoning should be impossible + var webRoutingSettings = new WebRoutingSettings + { + UmbracoApplicationUrl = "https://configured-site.com", + }; + + var hostingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == new HostingSettings()); + var webRoutingSettingsMonitor = Mock.Of>( + m => m.CurrentValue == webRoutingSettings); + + var webHostEnvironment = new Mock(); + webHostEnvironment.Setup(e => e.ContentRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.WebRootPath).Returns(Path.GetTempPath()); + webHostEnvironment.Setup(e => e.ApplicationName).Returns("TestApp"); + + var sut = new AspNetCoreHostingEnvironment( + hostingSettingsMonitor, + webRoutingSettingsMonitor, + webHostEnvironment.Object); + + Assert.AreEqual(new Uri("https://configured-site.com"), sut.ApplicationMainUrl); + + // Attempt override. + sut.EnsureApplicationMainUrl(new Uri("https://non-configured-site.com")); + + // Should remain configured value. + Assert.AreEqual( + new Uri("https://configured-site.com"), + sut.ApplicationMainUrl, + "Explicit config prevents host header poisoning"); + } }