Skip to content

Commit 2b8146f

Browse files
AndyButlandCopilot
andauthored
Media: Add protection to restrict access to media in recycle bin (closes #2931) (#20378)
* Add MoveFile it IFileSystem and implement on file systems. * Rename media file on move to recycle bin. * Rename file on restore from recycle bin. * Add configuration to enabled recycle bin media protection. * Expose backoffice authentication as cookie for non-backoffice usage. Protected requests for media in recycle bin. * Display protected image when viewing image cropper in the backoffice media recycle bin. * Code tidy and comments. * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Introduced helper class to DRY up repeated code between image cropper and file upload notification handlers. * Reverted client-side and management API updates. * Moved update of path to media file in recycle bin with deleted suffix to the server. * Separate integration tests for add and remove. * Use interpolated strings. * Renamed variable. * Move EnableMediaRecycleBinProtection to ContentSettings. * Tidied up comments. * Added TODO for 18. --------- Co-authored-by: Copilot <[email protected]>
1 parent b502e29 commit 2b8146f

File tree

24 files changed

+757
-34
lines changed

24 files changed

+757
-34
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Authentication;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Options;
5+
using OpenIddict.Server;
6+
using Umbraco.Cms.Core.Configuration.Models;
7+
using Umbraco.Cms.Core.Security;
8+
using Umbraco.Extensions;
9+
10+
namespace Umbraco.Cms.Infrastructure.Security;
11+
12+
/// <summary>
13+
/// Provides OpenIddict server event handlers to expose the backoffice authentication token via a custom authentication scheme.
14+
/// </summary>
15+
public class ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.GenerateTokenContext>,
16+
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyRevocationResponseContext>
17+
{
18+
private readonly IHttpContextAccessor _httpContextAccessor;
19+
private readonly string[] _claimTypes;
20+
private readonly TimeSpan _timeOut;
21+
22+
/// <summary>
23+
/// Initializes a new instance of the <see cref="ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler"/> class.
24+
/// </summary>
25+
public ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler(
26+
IHttpContextAccessor httpContextAccessor,
27+
IOptions<GlobalSettings> globalSettings,
28+
IOptions<BackOfficeIdentityOptions> backOfficeIdentityOptions)
29+
{
30+
_httpContextAccessor = httpContextAccessor;
31+
_timeOut = globalSettings.Value.TimeOut;
32+
33+
// These are the type identifiers for the claims required by the principal
34+
// for the custom authentication scheme.
35+
// We make available the ID, user name and allowed applications (sections) claims.
36+
_claimTypes =
37+
[
38+
backOfficeIdentityOptions.Value.ClaimsIdentity.UserIdClaimType,
39+
backOfficeIdentityOptions.Value.ClaimsIdentity.UserNameClaimType,
40+
Core.Constants.Security.AllowedApplicationsClaimType,
41+
];
42+
}
43+
44+
/// <inheritdoc/>
45+
/// <remarks>
46+
/// Event handler for when access tokens are generated (created or refreshed).
47+
/// </remarks>
48+
public async ValueTask HandleAsync(OpenIddictServerEvents.GenerateTokenContext context)
49+
{
50+
// Only proceed if this is a back-office sign-in.
51+
if (context.Principal.Identity?.AuthenticationType != Core.Constants.Security.BackOfficeAuthenticationType)
52+
{
53+
return;
54+
}
55+
56+
// Create a new principal with the claims from the authenticated principal.
57+
var principal = new ClaimsPrincipal(
58+
new ClaimsIdentity(
59+
context.Principal.Claims.Where(claim => _claimTypes.Contains(claim.Type)),
60+
Core.Constants.Security.BackOfficeExposedAuthenticationType));
61+
62+
// Sign-in the new principal for the custom authentication scheme.
63+
await _httpContextAccessor
64+
.GetRequiredHttpContext()
65+
.SignInAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, principal, GetAuthenticationProperties());
66+
}
67+
68+
/// <inheritdoc/>
69+
/// <remarks>
70+
/// Event handler for when access tokens are revoked.
71+
/// </remarks>
72+
public async ValueTask HandleAsync(OpenIddictServerEvents.ApplyRevocationResponseContext context)
73+
=> await _httpContextAccessor
74+
.GetRequiredHttpContext()
75+
.SignOutAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, GetAuthenticationProperties());
76+
77+
private AuthenticationProperties GetAuthenticationProperties()
78+
=> new()
79+
{
80+
IsPersistent = true,
81+
IssuedUtc = DateTimeOffset.UtcNow,
82+
ExpiresUtc = DateTimeOffset.UtcNow.Add(_timeOut)
83+
};
84+
}

src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
using Microsoft.AspNetCore.Builder;
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Http;
23
using Microsoft.Extensions.DependencyInjection;
4+
using OpenIddict.Server;
35
using Umbraco.Cms.Api.Common.DependencyInjection;
46
using Umbraco.Cms.Api.Management.Configuration;
57
using Umbraco.Cms.Api.Management.Handlers;
@@ -50,6 +52,7 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder)
5052
{
5153
builder.Services
5254
.AddAuthentication()
55+
5356
// Add our custom schemes which are cookie handlers
5457
.AddCookie(Constants.Security.BackOfficeAuthenticationType)
5558
.AddCookie(Constants.Security.BackOfficeExternalAuthenticationType, o =>
@@ -58,6 +61,15 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder)
5861
o.ExpireTimeSpan = TimeSpan.FromMinutes(5);
5962
})
6063

64+
// Add a cookie scheme that can be used for authenticating backoffice users outside the scope of the backoffice.
65+
.AddCookie(Constants.Security.BackOfficeExposedAuthenticationType, options =>
66+
{
67+
options.Cookie.Name = Constants.Security.BackOfficeExposedCookieName;
68+
options.Cookie.HttpOnly = true;
69+
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
70+
options.SlidingExpiration = true;
71+
})
72+
6173
// Although we don't natively support this, we add it anyways so that if end-users implement the required logic
6274
// they don't have to worry about manually adding this scheme or modifying the sign in manager
6375
.AddCookie(Constants.Security.BackOfficeTwoFactorAuthenticationType, options =>
@@ -71,6 +83,22 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder)
7183
o.ExpireTimeSpan = TimeSpan.FromMinutes(5);
7284
});
7385

86+
// Add OpnIddict server event handler to refresh the cookie that exposes the backoffice authentication outside the scope of the backoffice.
87+
builder.Services.AddSingleton<ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler>();
88+
builder.Services.Configure<OpenIddictServerOptions>(options =>
89+
{
90+
options.Handlers.Add(
91+
OpenIddictServerHandlerDescriptor
92+
.CreateBuilder<OpenIddictServerEvents.GenerateTokenContext>()
93+
.UseSingletonHandler<ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler>()
94+
.Build());
95+
options.Handlers.Add(
96+
OpenIddictServerHandlerDescriptor
97+
.CreateBuilder<OpenIddictServerEvents.ApplyRevocationResponseContext>()
98+
.UseSingletonHandler<ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler>()
99+
.Build());
100+
});
101+
74102
builder.Services.AddScoped<BackOfficeSecurityStampValidator>();
75103
builder.Services.ConfigureOptions<ConfigureBackOfficeCookieOptions>();
76104
builder.Services.ConfigureOptions<ConfigureBackOfficeSecurityStampValidatorOptions>();

src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,49 @@
11
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Options;
23
using Umbraco.Cms.Api.Management.Mapping.Content;
34
using Umbraco.Cms.Api.Management.ViewModels.Media;
45
using Umbraco.Cms.Api.Management.ViewModels.Media.Collection;
56
using Umbraco.Cms.Api.Management.ViewModels.MediaType;
7+
using Umbraco.Cms.Core.Configuration.Models;
68
using Umbraco.Cms.Core.DependencyInjection;
79
using Umbraco.Cms.Core.Mapping;
810
using Umbraco.Cms.Core.Models;
911
using Umbraco.Cms.Core.Models.Mapping;
1012
using Umbraco.Cms.Core.PropertyEditors;
13+
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
1114
using Umbraco.Extensions;
1215

1316
namespace Umbraco.Cms.Api.Management.Mapping.Media;
1417

1518
public class MediaMapDefinition : ContentMapDefinition<IMedia, MediaValueResponseModel, MediaVariantResponseModel>, IMapDefinition
1619
{
1720
private readonly CommonMapper _commonMapper;
21+
private ContentSettings _contentSettings;
1822

1923
public MediaMapDefinition(
2024
PropertyEditorCollection propertyEditorCollection,
2125
CommonMapper commonMapper,
22-
IDataValueEditorFactory dataValueEditorFactory)
26+
IDataValueEditorFactory dataValueEditorFactory,
27+
IOptionsMonitor<ContentSettings> contentSettings)
2328
: base(propertyEditorCollection, dataValueEditorFactory)
24-
=> _commonMapper = commonMapper;
29+
{
30+
_commonMapper = commonMapper;
31+
_contentSettings = contentSettings.CurrentValue;
32+
contentSettings.OnChange(x => _contentSettings = x);
33+
}
34+
35+
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 18.")]
36+
public MediaMapDefinition(
37+
PropertyEditorCollection propertyEditorCollection,
38+
CommonMapper commonMapper,
39+
IDataValueEditorFactory dataValueEditorFactory)
40+
: this(
41+
propertyEditorCollection,
42+
commonMapper,
43+
dataValueEditorFactory,
44+
StaticServiceProvider.Instance.GetRequiredService<IOptionsMonitor<ContentSettings>>())
45+
{
46+
}
2547

2648
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 18.")]
2749
public MediaMapDefinition(
@@ -48,6 +70,39 @@ private void Map(IMedia source, MediaResponseModel target, MapperContext context
4870
target.Values = MapValueViewModels(source.Properties);
4971
target.Variants = MapVariantViewModels(source);
5072
target.IsTrashed = source.Trashed;
73+
74+
// If protection for media files in the recycle bin is enabled, and the media item is trashed, amend the value of the file path
75+
// to have the `.deleted` suffix that will have been added to the persisted file.
76+
if (target.IsTrashed && _contentSettings.EnableMediaRecycleBinProtection)
77+
{
78+
foreach (MediaValueResponseModel valueModel in target.Values
79+
.Where(x => x.EditorAlias.Equals(Core.Constants.PropertyEditors.Aliases.ImageCropper)))
80+
{
81+
if (valueModel.Value is not null &&
82+
valueModel.Value is ImageCropperValue imageCropperValue &&
83+
string.IsNullOrWhiteSpace(imageCropperValue.Src) is false)
84+
{
85+
valueModel.Value = new ImageCropperValue
86+
{
87+
Crops = imageCropperValue.Crops,
88+
FocalPoint = imageCropperValue.FocalPoint,
89+
TemporaryFileId = imageCropperValue.TemporaryFileId,
90+
Src = SuffixMediaPath(imageCropperValue.Src, Core.Constants.Conventions.Media.TrashedMediaSuffix),
91+
};
92+
}
93+
}
94+
}
95+
}
96+
97+
private static string SuffixMediaPath(string filePath, string suffix)
98+
{
99+
int lastDotIndex = filePath.LastIndexOf('.');
100+
if (lastDotIndex == -1)
101+
{
102+
return filePath + suffix;
103+
}
104+
105+
return filePath[..lastDotIndex] + suffix + filePath[lastDotIndex..];
51106
}
52107

53108
// Umbraco.Code.MapAll -Flags

src/Umbraco.Core/Configuration/Models/ContentSettings.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ public class ContentSettings
3131
internal const bool StaticShowDomainWarnings = true;
3232
internal const bool StaticShowUnroutableContentWarnings = true;
3333

34+
// TODO (V18): Consider enabling this by default and documenting as a behavioural breaking change.
35+
private const bool StaticEnableMediaRecycleBinProtection = false;
36+
3437
/// <summary>
3538
/// Gets or sets a value for the content notification settings.
3639
/// </summary>
@@ -158,4 +161,16 @@ public class ContentSettings
158161
/// </summary>
159162
[DefaultValue(StaticShowUnroutableContentWarnings)]
160163
public bool ShowUnroutableContentWarnings { get; set; } = StaticShowUnroutableContentWarnings;
164+
165+
/// <summary>
166+
/// Gets or sets a value indicating whether to enable or disable the recycle bin protection for media.
167+
/// </summary>
168+
/// <remarks>
169+
/// When set to true, this will:
170+
/// - Rename media moved to the recycle bin to have a .deleted suffice (e.g. image.jpg will be renamed to image.deleted.jpg).
171+
/// - On restore, the media file will be renamed back to its original name.
172+
/// - A middleware component will be enabled to prevent access to media files in the recycle bin unless the user is authenticated with access to the media section.
173+
/// </remarks>
174+
[DefaultValue(StaticEnableMediaRecycleBinProtection)]
175+
public bool EnableMediaRecycleBinProtection { get; set; } = StaticEnableMediaRecycleBinProtection;
161176
}

src/Umbraco.Core/Configuration/Models/ImagingSettings.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Umbraco.
22
// See LICENSE for more details.
33

4+
using System.ComponentModel;
5+
46
namespace Umbraco.Cms.Core.Configuration.Models;
57

68
/// <summary>

src/Umbraco.Core/Constants-Conventions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ public static class Media
100100
/// The default height/width of an image file if the size can't be determined from the metadata
101101
/// </summary>
102102
public const int DefaultSize = 200;
103+
104+
/// <summary>
105+
/// Suffix added to media files when moved to the recycle bin when recycle bin media protection is enabled.
106+
/// </summary>
107+
public const string TrashedMediaSuffix = ".deleted";
103108
}
104109

105110
/// <summary>

src/Umbraco.Core/Constants-Security.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ public static class Security
7373
public const string BackOfficeTokenAuthenticationType = "UmbracoBackOfficeToken";
7474
public const string BackOfficeTwoFactorAuthenticationType = "UmbracoTwoFactorCookie";
7575
public const string BackOfficeTwoFactorRememberMeAuthenticationType = "UmbracoTwoFactorRememberMeCookie";
76+
77+
/// <summary>
78+
/// Authentication type and scheme used for backoffice users when it is exposed out of the backoffice context via a cookie.
79+
/// </summary>
80+
public const string BackOfficeExposedAuthenticationType = "UmbracoBackOfficeExposed";
81+
82+
/// <summary>
83+
/// Represents the name of the authentication cookie used to expose the backoffice authentication token outside of the backoffice context.
84+
/// </summary>
85+
public const string BackOfficeExposedCookieName = "UMB_UCONTEXT_EXPOSED";
86+
7687
public const string EmptyPasswordPrefix = "___UIDEMPTYPWORD__";
7788

7889
public const string DefaultMemberTypeAlias = "Member";

src/Umbraco.Core/IO/IFileSystem.cs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,42 @@ public interface IFileSystem
168168
/// <param name="copy">A value indicating whether to move (default) or copy.</param>
169169
void AddFile(string path, string physicalPath, bool overrideIfExists = true, bool copy = false);
170170

171+
/// <summary>
172+
/// Moves a file from the specified source path to the specified target path.
173+
/// </summary>
174+
/// <param name="source">The path of the file or directory to move.</param>
175+
/// <param name="target">The destination path where the file or directory will be moved.</param>
176+
/// <param name="overrideIfExists">A value indicating what to do if the file already exists.</param>
177+
void MoveFile(string source, string target, bool overrideIfExists = true)
178+
{
179+
// Provide a default implementation for implementations of IFileSystem that do not implement this method.
180+
if (FileExists(source) is false)
181+
{
182+
throw new FileNotFoundException($"File at path '{source}' could not be found.");
183+
}
184+
185+
if (FileExists(target))
186+
{
187+
if (overrideIfExists)
188+
{
189+
DeleteFile(target);
190+
}
191+
else
192+
{
193+
throw new IOException($"A file at path '{target}' already exists.");
194+
}
195+
}
196+
197+
using (Stream sourceStream = OpenFile(source))
198+
{
199+
AddFile(target, sourceStream);
200+
}
201+
202+
DeleteFile(source);
203+
}
204+
171205
// TODO: implement these
172206
//
173207
// void CreateDirectory(string path);
174208
//
175-
//// move or rename, directory or file
176-
// void Move(string source, string target);
177209
}

0 commit comments

Comments
 (0)