From 0166727eee4db8d25f191026a506a6c54d755fd7 Mon Sep 17 00:00:00 2001 From: Andy Butland <abutland73@gmail.com> Date: Tue, 7 Jan 2025 10:13:02 +0100 Subject: [PATCH 01/10] Bumped version to 14.3.2. --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 05117000e84a..76e8b898c188 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "14.3.1", + "version": "14.3.2", "assemblyVersion": { "precision": "build" }, From 86f30333343428abfaa9175307be0fed76711cf2 Mon Sep 17 00:00:00 2001 From: Andy Butland <abutland73@gmail.com> Date: Mon, 20 Jan 2025 14:14:28 +0100 Subject: [PATCH 02/10] Merge commit from fork --- src/Umbraco.Web.Common/Views/UmbracoViewPage.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs b/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs index f851acab3b34..10a80a1e78d8 100644 --- a/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs +++ b/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs @@ -141,7 +141,10 @@ public void WriteUmbracoContent(TagHelperOutput tagHelperOutput) string.Format( ContentSettings.PreviewBadge, HostingEnvironment.ToAbsolute(GlobalSettings.UmbracoPath), - Context.Request.GetEncodedUrl(), + System.Web.HttpUtility.HtmlEncode(Context.Request.GetEncodedUrl()), // Belt and braces - via a browser at least it doesn't seem possible to have anything other than + // a valid culture code provided in the querystring of this URL. + // But just to be sure of prevention of an XSS vulnterablity we'll HTML encode here too. + // An expected URL is untouched by this encoding. UmbracoContext.PublishedRequest?.PublishedContent?.Key); } else From c65204a14688c7786f97eae99b6d4203a9ab8c9c Mon Sep 17 00:00:00 2001 From: Bjarke Berg <mail@bergmania.dk> Date: Wed, 16 Oct 2024 19:47:34 +0200 Subject: [PATCH 03/10] update backoffice submodule to 14.3.2 --- src/Umbraco.Web.UI.Client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 586bde9f2316..77a2ba77b592 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 586bde9f23168c08c519f143dbd7463bbe71eea5 +Subproject commit 77a2ba77b592d66331e5196f12bca865b703ff0d From e8d6cded2b67e4711f619c7b9c39e489f392eb56 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:42:17 +0100 Subject: [PATCH 04/10] update backoffice submodule to 14.3.2 --- src/Umbraco.Web.UI.Client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 77a2ba77b592..7831615e2824 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 77a2ba77b592d66331e5196f12bca865b703ff0d +Subproject commit 7831615e282410d82d1e7629109d03d0ea783bc5 From a0a4af6a0c59d2fe37ebeda333e10ba84830a9e7 Mon Sep 17 00:00:00 2001 From: Andy Butland <abutland73@gmail.com> Date: Mon, 20 Jan 2025 14:54:14 +0100 Subject: [PATCH 05/10] Merge commit from fork --- .../Security/BackOfficeController.cs | 86 ++++++++++++------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index 5a247a647c6a..1dbf5daa0086 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs @@ -34,6 +34,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security; [ApiExplorerSettings(IgnoreApi = true)] public class BackOfficeController : SecurityControllerBase { + private static long? _loginDurationAverage; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly IBackOfficeSignInManager _backOfficeSignInManager; private readonly IBackOfficeUserManager _backOfficeUserManager; @@ -72,45 +74,65 @@ public BackOfficeController( [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] public async Task<IActionResult> Login(CancellationToken cancellationToken, LoginRequestModel model) { - IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync( - model.Username, model.Password, true, true); + // Start a timed scope to ensure failed responses return is a consistent time + var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.Value.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.Value.UserMinimumFailedLoginDurationInMilliseconds); + await using var timedScope = new TimedScope(loginDuration, cancellationToken); - if (result.IsNotAllowed) + IdentitySignInResult result = await _backOfficeSignInManager.PasswordSignInAsync(model.Username, model.Password, true, true); + if (result.Succeeded is false) { - return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() - .WithTitle("User is not allowed") - .WithDetail("The operation is not allowed on the user") - .Build()); - } + // TODO: The result should include the user and whether the credentials were valid to avoid these additional checks + BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByNameAsync(model.Username.Trim()); // Align with UmbracoSignInManager and trim username! + if (user is not null && + await _backOfficeUserManager.CheckPasswordAsync(user, model.Password)) + { + // The credentials were correct, so cancel timed scope and provide a more detailed failure response + await timedScope.CancelAsync(); + + if (result.IsNotAllowed) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is not allowed") + .WithDetail("The operation is not allowed on the user") + .Build()); + } + + if (result.IsLockedOut) + { + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() + .WithTitle("User is locked") + .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") + .Build()); + } + + if (result.RequiresTwoFactor) + { + string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username); + IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(user.Key)).Result.Where(x => x.IsEnabledOnUser).Select(x => x.ProviderName); + + return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel() + { + TwoFactorLoginView = twofactorView, + EnabledTwoFactorProviderNames = enabledProviders + }); + } + } - if (result.IsLockedOut) - { - return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetailsBuilder() - .WithTitle("User is locked") - .WithDetail("The user is locked, and need to be unlocked before more login attempts can be executed.") + return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder() + .WithTitle("Invalid credentials") + .WithDetail("The provided credentials are invalid. User has not been signed in.") .Build()); } - if(result.RequiresTwoFactor) - { - string? twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(model.Username); - BackOfficeIdentityUser? attemptingUser = await _backOfficeUserManager.FindByNameAsync(model.Username); - IEnumerable<string> enabledProviders = (await _userTwoFactorLoginService.GetProviderNamesAsync(attemptingUser!.Key)).Result.Where(x=>x.IsEnabledOnUser).Select(x=>x.ProviderName); - return StatusCode(StatusCodes.Status402PaymentRequired, new RequiresTwoFactorResponseModel() - { - TwoFactorLoginView = twofactorView, - EnabledTwoFactorProviderNames = enabledProviders - }); - } + // Set initial or update average (successful) login duration + _loginDurationAverage = _loginDurationAverage is long average + ? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2 + : (long)timedScope.Elapsed.TotalMilliseconds; - if (result.Succeeded) - { - return Ok(); - } - return StatusCode(StatusCodes.Status401Unauthorized, new ProblemDetailsBuilder() - .WithTitle("Invalid credentials") - .WithDetail("The provided credentials are invalid. User has not been signed in.") - .Build()); + // Cancel the timed scope (we don't want to unnecessarily wait on a successful response) + await timedScope.CancelAsync(); + + return Ok(); } [AllowAnonymous] From 65bb2801b0d7bf9eb5099dd56ec306050bf7897c Mon Sep 17 00:00:00 2001 From: Ronald Barendse <ronald@barend.se> Date: Mon, 20 Jan 2025 14:54:14 +0100 Subject: [PATCH 06/10] Merge commit from fork * Add TimedScope * Use TimedScope in login endpoint * Use seperate default duration and only calculate average of actual successful responses * Only return detailed error responses if credentials are valid * Cancel timed scope when credentials are valid * Add UserDefaultFailedLoginDuration and UserMinimumFailedLoginDuration settings --- .../Configuration/Models/SecuritySettings.cs | 27 +++ src/Umbraco.Core/TimedScope.cs | 162 ++++++++++++++++++ src/Umbraco.Web.UI.Client | 2 +- 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/Umbraco.Core/TimedScope.cs diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index 2a57a0a74c7c..803bb2ecc27e 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.ComponentModel; +using System.ComponentModel.DataAnnotations; namespace Umbraco.Cms.Core.Configuration.Models; @@ -25,6 +26,8 @@ public class SecuritySettings internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60; internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60; + private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000; + private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250; internal const string StaticAuthorizeCallbackPathName = "/umbraco/oauth_complete"; internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout"; internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error"; @@ -108,6 +111,30 @@ public class SecuritySettings [DefaultValue(StaticAllowConcurrentLogins)] public bool AllowConcurrentLogins { get; set; } = StaticAllowConcurrentLogins; + /// <summary> + /// Gets or sets the default duration (in milliseconds) of failed login attempts. + /// </summary> + /// <value> + /// The default duration (in milliseconds) of failed login attempts. + /// </value> + /// <remarks> + /// The user login endpoint ensures that failed login attempts take at least as long as the average successful login. + /// However, if no successful logins have occurred, this value is used as the default duration. + /// </remarks> + [Range(0, long.MaxValue)] + [DefaultValue(StaticUserDefaultFailedLoginDurationInMilliseconds)] + public long UserDefaultFailedLoginDurationInMilliseconds { get; set; } = StaticUserDefaultFailedLoginDurationInMilliseconds; + + /// <summary> + /// Gets or sets the minimum duration (in milliseconds) of failed login attempts. + /// </summary> + /// <value> + /// The minimum duration (in milliseconds) of failed login attempts. + /// </value> + [Range(0, long.MaxValue)] + [DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)] + public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds; + /// <summary> /// Gets or sets a value of the back-office host URI. Use this when running the back-office client and the Management API on different hosts. Leave empty when running both on the same host. /// </summary> diff --git a/src/Umbraco.Core/TimedScope.cs b/src/Umbraco.Core/TimedScope.cs new file mode 100644 index 000000000000..f12f0e90eda8 --- /dev/null +++ b/src/Umbraco.Core/TimedScope.cs @@ -0,0 +1,162 @@ +namespace Umbraco.Cms.Core; + +/// <summary> +/// Makes a code block timed (take at least a certain amount of time). This class cannot be inherited. +/// </summary> +public sealed class TimedScope : IDisposable, IAsyncDisposable +{ + private readonly TimeSpan _duration; + private readonly TimeProvider _timeProvider; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly long _startingTimestamp; + + /// <summary> + /// Gets the elapsed time. + /// </summary> + /// <value> + /// The elapsed time. + /// </value> + public TimeSpan Elapsed + => _timeProvider.GetElapsedTime(_startingTimestamp); + + /// <summary> + /// Gets the remaining time. + /// </summary> + /// <value> + /// The remaining time. + /// </value> + public TimeSpan Remaining + => TryGetRemaining(out TimeSpan remaining) ? remaining : TimeSpan.Zero; + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope" /> class. + /// </summary> + /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param> + public TimedScope(long millisecondsDuration) + : this(TimeSpan.FromMilliseconds(millisecondsDuration)) + { } + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope" /> class. + /// </summary> + /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public TimedScope(long millisecondsDuration, CancellationToken cancellationToken) + : this(TimeSpan.FromMilliseconds(millisecondsDuration), cancellationToken) + { } + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope" /> class. + /// </summary> + /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param> + /// <param name="timeProvider">The time provider.</param> + public TimedScope(long millisecondsDuration, TimeProvider timeProvider) + : this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider) + { } + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope" /> class. + /// </summary> + /// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param> + /// <param name="timeProvider">The time provider.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public TimedScope(long millisecondsDuration, TimeProvider timeProvider, CancellationToken cancellationToken) + : this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider, cancellationToken) + { } + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope"/> class. + /// </summary> + /// <param name="duration">The duration the scope should at least take.</param> + public TimedScope(TimeSpan duration) + : this(duration, TimeProvider.System) + { } + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope" /> class. + /// </summary> + /// <param name="duration">The duration the scope should at least take.</param> + /// <param name="timeProvider">The time provider.</param> + public TimedScope(TimeSpan duration, TimeProvider timeProvider) + : this(duration, timeProvider, new CancellationTokenSource()) + { } + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope" /> class. + /// </summary> + /// <param name="duration">The duration the scope should at least take.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public TimedScope(TimeSpan duration, CancellationToken cancellationToken) + : this(duration, TimeProvider.System, cancellationToken) + { } + + /// <summary> + /// Initializes a new instance of the <see cref="TimedScope" /> class. + /// </summary> + /// <param name="duration">The duration the scope should at least take.</param> + /// <param name="timeProvider">The time provider.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationToken cancellationToken) + : this(duration, timeProvider, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { } + + private TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationTokenSource cancellationTokenSource) + { + _duration = duration; + _timeProvider = timeProvider; + _cancellationTokenSource = cancellationTokenSource; + _startingTimestamp = timeProvider.GetTimestamp(); + } + + /// <summary> + /// Cancels the timed scope. + /// </summary> + public void Cancel() + => _cancellationTokenSource.Cancel(); + + /// <summary> + /// Cancels the timed scope asynchronously. + /// </summary> + public async Task CancelAsync() + => await _cancellationTokenSource.CancelAsync().ConfigureAwait(false); + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + /// <remarks> + /// This will block using <see cref="Thread.Sleep(TimeSpan)" /> until the remaining time has elapsed, if not cancelled. + /// </remarks> + public void Dispose() + { + if (_cancellationTokenSource.IsCancellationRequested is false && + TryGetRemaining(out TimeSpan remaining)) + { + Thread.Sleep(remaining); + } + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously. + /// </summary> + /// <returns> + /// A task that represents the asynchronous dispose operation. + /// </returns> + /// <remarks> + /// This will delay using <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)" /> until the remaining time has elapsed, if not cancelled. + /// </remarks> + public async ValueTask DisposeAsync() + { + if (_cancellationTokenSource.IsCancellationRequested is false && + TryGetRemaining(out TimeSpan remaining)) + { + await Task.Delay(remaining, _timeProvider, _cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + private bool TryGetRemaining(out TimeSpan remaining) + { + remaining = _duration.Subtract(Elapsed); + + return remaining > TimeSpan.Zero; + } +} diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 7831615e2824..586bde9f2316 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 7831615e282410d82d1e7629109d03d0ea783bc5 +Subproject commit 586bde9f23168c08c519f143dbd7463bbe71eea5 From abc312c9b45440c0f311dc95354db0949dbf9808 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 21 Jan 2025 08:44:10 +0100 Subject: [PATCH 07/10] update backoffice submodule to 14.3.2 --- src/Umbraco.Web.UI.Client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 586bde9f2316..b530e31bad78 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 586bde9f23168c08c519f143dbd7463bbe71eea5 +Subproject commit b530e31bad78471222e0d67080153c3a5dbbb48a From 14ed3348bfd0027b5bc841825630859898b8a7f9 Mon Sep 17 00:00:00 2001 From: Andy Butland <abutland73@gmail.com> Date: Sun, 9 Mar 2025 08:54:03 +0100 Subject: [PATCH 08/10] bumped imagesharp to prevent CVE-2025-27598 (#18602) # Conflicts: # Directory.Packages.props --- Directory.Packages.props | 2 +- .../Umbraco.Cms.Imaging.ImageSharp2.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 027876da6fcf..0c6f6aa0d025 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,7 +73,7 @@ <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageVersion Include="Serilog.Sinks.Map" Version="1.0.2" /> - <PackageVersion Include="SixLabors.ImageSharp" Version="3.1.5" /> + <PackageVersion Include="SixLabors.ImageSharp" Version="3.1.7" /> <PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.3" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.7.3" /> </ItemGroup> diff --git a/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj b/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj index 16bac191d5ec..7563b27d7426 100644 --- a/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj +++ b/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj @@ -4,7 +4,7 @@ <Description>Adds imaging support using ImageSharp/ImageSharp.Web version 2 to Umbraco CMS.</Description> </PropertyGroup> <ItemGroup> - <PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.9, 3)" /> + <PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.10, 3)" /> <PackageReference Include="SixLabors.ImageSharp.Web" VersionOverride="[2.0.2, 3)" /> </ItemGroup> From d9fb6df16e9adf8656181cac8497fc5ba23321cd Mon Sep 17 00:00:00 2001 From: Andy Butland <abutland73@gmail.com> Date: Tue, 11 Mar 2025 05:11:35 +0100 Subject: [PATCH 09/10] Merge commit from fork * Bumped version to 15.2.1. # Conflicts: # version.json * Tighten management API endpoint access rules. --- .../Controllers/DataType/CopyDataTypeController.cs | 3 +++ .../Controllers/DataType/CreateDataTypeController.cs | 5 ++++- .../Controllers/DataType/DeleteDataTypeController.cs | 5 ++++- .../Controllers/DataType/MoveDataTypeController.cs | 3 +++ .../Controllers/DataType/UpdateDataTypeController.cs | 5 ++++- .../Controllers/DocumentType/ExportDocumentTypeController.cs | 3 +++ .../DocumentType/ImportExistingDocumentTypeController.cs | 4 +++- .../DocumentType/ImportNewDocumentTypeController.cs | 4 +++- .../Controllers/MediaType/ExportMediaTypeController.cs | 3 +++ .../MediaType/ImportExistingMediaTypeController.cs | 4 +++- .../Controllers/MediaType/ImportNewMediaTypeController.cs | 5 +++-- .../Controllers/MediaType/MediaTypeControllerBase.cs | 3 +-- version.json | 2 +- 13 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/CopyDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/CopyDataTypeController.cs index 2bafe735324f..6bf47269d5ce 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/CopyDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/CopyDataTypeController.cs @@ -1,4 +1,5 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.DataType; @@ -7,10 +8,12 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DataType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)] public class CopyDataTypeController : DataTypeControllerBase { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/CreateDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/CreateDataTypeController.cs index 9275e25001c0..1acea3969626 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/CreateDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/CreateDataTypeController.cs @@ -1,4 +1,5 @@ -using Asp.Versioning; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; @@ -8,10 +9,12 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DataType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)] public class CreateDataTypeController : DataTypeControllerBase { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DeleteDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DeleteDataTypeController.cs index aed80dc5fedf..ba6cb63d8f98 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DeleteDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DeleteDataTypeController.cs @@ -1,4 +1,5 @@ -using Asp.Versioning; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; @@ -6,10 +7,12 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DataType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)] public class DeleteDataTypeController : DataTypeControllerBase { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/MoveDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/MoveDataTypeController.cs index bec84124b500..e8f5230463a7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/MoveDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/MoveDataTypeController.cs @@ -1,4 +1,5 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.DataType; @@ -7,10 +8,12 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DataType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)] public class MoveDataTypeController : DataTypeControllerBase { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/UpdateDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/UpdateDataTypeController.cs index 71afc06b26ea..4b67b55653ce 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/UpdateDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/UpdateDataTypeController.cs @@ -1,4 +1,5 @@ -using Asp.Versioning; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; @@ -8,10 +9,12 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DataType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)] public class UpdateDataTypeController : DataTypeControllerBase { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ExportDocumentTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ExportDocumentTypeController.cs index d0c66d059995..4d2eba27eb84 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ExportDocumentTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ExportDocumentTypeController.cs @@ -1,14 +1,17 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DocumentType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] public class ExportDocumentTypeController : DocumentTypeControllerBase { private readonly IContentTypeService _contentTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportExistingDocumentTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportExistingDocumentTypeController.cs index 9b1d6506a1db..46f28d366520 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportExistingDocumentTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportExistingDocumentTypeController.cs @@ -1,17 +1,19 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.ImportExport; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DocumentType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] public class ImportExistingDocumentTypeController : DocumentTypeControllerBase { private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportNewDocumentTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportNewDocumentTypeController.cs index 5b4fbde19910..9b0cb2af0d76 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportNewDocumentTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/ImportNewDocumentTypeController.cs @@ -1,17 +1,19 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.ImportExport; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.DocumentType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] public class ImportNewDocumentTypeController : DocumentTypeControllerBase { private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ExportMediaTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ExportMediaTypeController.cs index 12f8540a4ea6..40423c3b1d73 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ExportMediaTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ExportMediaTypeController.cs @@ -1,14 +1,17 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.MediaType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] public class ExportMediaTypeController : MediaTypeControllerBase { private readonly IMediaTypeService _mediaTypeService; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportExistingMediaTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportExistingMediaTypeController.cs index 9c4ba5ed9592..9aedac01e48a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportExistingMediaTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportExistingMediaTypeController.cs @@ -1,17 +1,19 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.MediaType; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.ImportExport; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.MediaType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] public class ImportExistingMediaTypeController : MediaTypeControllerBase { private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportNewMediaTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportNewMediaTypeController.cs index 868822e49498..e71e2b2abb72 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportNewMediaTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/ImportNewMediaTypeController.cs @@ -1,18 +1,19 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.Controllers.DocumentType; using Umbraco.Cms.Api.Management.ViewModels.MediaType; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.ImportExport; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.MediaType; [ApiVersion("1.0")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] public class ImportNewMediaTypeController : MediaTypeControllerBase { private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/MediaTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/MediaTypeControllerBase.cs index 38d11c175d5f..387d260cde62 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/MediaTypeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/MediaTypeControllerBase.cs @@ -1,9 +1,8 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Controllers.DocumentType; using Umbraco.Cms.Api.Management.Routing; -using Umbraco.Cms.Api.Management.ViewModels.MediaType; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Web.Common.Authorization; diff --git a/version.json b/version.json index 76e8b898c188..da7e484de4ec 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "14.3.2", + "version": "14.3.3", "assemblyVersion": { "precision": "build" }, From d3c1443b14b1076faf13d1bcecc42860fdf5fad8 Mon Sep 17 00:00:00 2001 From: Andy Butland <abutland73@gmail.com> Date: Tue, 8 Apr 2025 05:03:40 +0200 Subject: [PATCH 10/10] Merge commit from fork * Prevent path traveral vulnerability with upload of temporary files. * Used BadRequest instead of NotFound for invalid file name response. --- .../TemporaryFileControllerBase.cs | 5 +- .../TemporaryFileUploadStatus.cs | 3 +- .../Services/TemporaryFileService.cs | 28 ++++-- .../Services/TemporaryFileServiceTests.cs | 86 +++++++++++++++++++ version.json | 2 +- 5 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs index b33f63d7ac94..ac808918feb3 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Routing; @@ -17,6 +17,9 @@ protected IActionResult TemporaryFileStatusResult(TemporaryFileOperationStatus o .WithTitle("File extension not allowed") .WithDetail("The file extension is not allowed.") .Build()), + TemporaryFileOperationStatus.InvalidFileName => BadRequest(problemDetailsBuilder + .WithTitle("The provided file name is not valid") + .Build()), TemporaryFileOperationStatus.KeyAlreadyUsed => BadRequest(problemDetailsBuilder .WithTitle("Key already used") .WithDetail("The specified key is already used.") diff --git a/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs b/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs index caa5b9e0546b..3f6d8152514d 100644 --- a/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs @@ -6,5 +6,6 @@ public enum TemporaryFileOperationStatus FileExtensionNotAllowed = 1, KeyAlreadyUsed = 2, NotFound = 3, - UploadBlocked + UploadBlocked = 4, + InvalidFileName = 5, } diff --git a/src/Umbraco.Core/Services/TemporaryFileService.cs b/src/Umbraco.Core/Services/TemporaryFileService.cs index 6dc964d23dbf..12a78b07396b 100644 --- a/src/Umbraco.Core/Services/TemporaryFileService.cs +++ b/src/Umbraco.Core/Services/TemporaryFileService.cs @@ -45,7 +45,6 @@ public TemporaryFileService( return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(TemporaryFileOperationStatus.KeyAlreadyUsed, null); } - await using Stream dataStream = createModel.OpenReadStream(); dataStream.Seek(0, SeekOrigin.Begin); if (_fileStreamSecurityValidator.IsConsideredSafe(dataStream) is false) @@ -53,13 +52,12 @@ public TemporaryFileService( return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(TemporaryFileOperationStatus.UploadBlocked, null); } - temporaryFileModel = new TemporaryFileModel { Key = createModel.Key, FileName = createModel.FileName, OpenReadStream = createModel.OpenReadStream, - AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime) + AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime), }; await _temporaryFileRepository.SaveAsync(temporaryFileModel); @@ -68,17 +66,29 @@ public TemporaryFileService( } private TemporaryFileOperationStatus Validate(TemporaryFileModelBase temporaryFileModel) - => IsAllowedFileExtension(temporaryFileModel) == false - ? TemporaryFileOperationStatus.FileExtensionNotAllowed - : TemporaryFileOperationStatus.Success; - - private bool IsAllowedFileExtension(TemporaryFileModelBase temporaryFileModel) { - var extension = Path.GetExtension(temporaryFileModel.FileName)[1..]; + if (IsAllowedFileExtension(temporaryFileModel.FileName) == false) + { + return TemporaryFileOperationStatus.FileExtensionNotAllowed; + } + + if (IsValidFileName(temporaryFileModel.FileName) == false) + { + return TemporaryFileOperationStatus.InvalidFileName; + } + + return TemporaryFileOperationStatus.Success; + } + private bool IsAllowedFileExtension(string fileName) + { + var extension = Path.GetExtension(fileName)[1..]; return _contentSettings.IsFileAllowedForUpload(extension); } + private static bool IsValidFileName(string fileName) => + !string.IsNullOrEmpty(fileName) && fileName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + public async Task<Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>> DeleteAsync(Guid key) { TemporaryFileModel? model = await _temporaryFileRepository.GetAsync(key); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs new file mode 100644 index 000000000000..4197b4a73bcd --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] +public class TemporaryFileServiceTests : UmbracoIntegrationTest +{ + private ITemporaryFileService TemporaryFileService => GetRequiredService<ITemporaryFileService>(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) => + builder.Services.Configure<ContentSettings>(config => + config.AllowedUploadedFileExtensions = ["txt"]); + + [Test] + public async Task Can_Create_Get_And_Delete_Temporary_File() + { + var key = Guid.NewGuid(); + const string FileName = "test.txt"; + const string FileContents = "test"; + var model = new CreateTemporaryFileModel + { + FileName = FileName, + Key = key, + OpenReadStream = () => + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(FileContents); + writer.Flush(); + stream.Position = 0; + return stream; + } + }; + var createAttempt = await TemporaryFileService.CreateAsync(model); + Assert.IsTrue(createAttempt.Success); + + TemporaryFileModel? fileModel = await TemporaryFileService.GetAsync(key); + Assert.IsNotNull(fileModel); + Assert.AreEqual(key, fileModel.Key); + Assert.AreEqual(FileName, fileModel.FileName); + + using (var reader = new StreamReader(fileModel.OpenReadStream())) + { + string fileContents = reader.ReadToEnd(); + Assert.AreEqual(FileContents, fileContents); + } + + var deleteAttempt = await TemporaryFileService.DeleteAsync(key); + Assert.IsTrue(createAttempt.Success); + + fileModel = await TemporaryFileService.GetAsync(key); + Assert.IsNull(fileModel); + } + + [Test] + public async Task Cannot_Create_File_Outside_Of_Temporary_Files_Root() + { + var key = Guid.NewGuid(); + const string FileName = "../test.txt"; + var model = new CreateTemporaryFileModel + { + FileName = FileName, + Key = key, + OpenReadStream = () => + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(string.Empty); + writer.Flush(); + stream.Position = 0; + return stream; + } + }; + var createAttempt = await TemporaryFileService.CreateAsync(model); + Assert.IsFalse(createAttempt.Success); + Assert.AreEqual(TemporaryFileOperationStatus.InvalidFileName, createAttempt.Status); + } +} diff --git a/version.json b/version.json index da7e484de4ec..2d743d3c4a26 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "14.3.3", + "version": "14.3.4", "assemblyVersion": { "precision": "build" },