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