diff --git a/src/OrchardCore.Cms.Web/appsettings.json b/src/OrchardCore.Cms.Web/appsettings.json
index 77a207c80da..8811e208b7a 100644
--- a/src/OrchardCore.Cms.Web/appsettings.json
+++ b/src/OrchardCore.Cms.Web/appsettings.json
@@ -40,6 +40,7 @@
//"OrchardCore_Media": {
// "SupportedSizes": [ 16, 32, 50, 100, 160, 240, 480, 600, 1024, 2048 ],
// "MaxBrowserCacheDays": 30,
+ // "MaxSecureFilesBrowserCacheDays": 0,
// "MaxCacheDays": 365,
// "ResizedCacheMaxStale": "01:00:00", // The time before a stale item is removed from the resized media cache, if not provided there is no cleanup.
// "RemoteCacheMaxStale": "01:00:00", // The time before a stale item is removed from the remote media cache, if not provided there is no cleanup.
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/app.js b/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/app.js
index d6a4768c81c..679dd452919 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/app.js
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/app.js
@@ -27,7 +27,8 @@ function initializeMediaApplication(displayMediaApplication, mediaApplicationUrl
name: $('#t-mediaLibrary').text(),
path: '',
folder: '',
- isDirectory: true
+ isDirectory: true,
+ canCreateFolder: $('#allowNewRootFolders').val() === 'true'
};
mediaApp = new Vue({
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/folderComponent.js b/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/folderComponent.js
index a7a627356d1..bbdb0e24a18 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/folderComponent.js
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Assets/js/app/MediaApp/folderComponent.js
@@ -10,8 +10,8 @@ Vue.component('folder', {
{{model.name}}
-
-
+
+
@@ -48,6 +48,12 @@ Vue.component('folder', {
},
isRoot: function () {
return this.model.path === '';
+ },
+ canCreateFolder: function () {
+ return this.model.canCreateFolder !== undefined ? this.model.canCreateFolder : true;
+ },
+ canDeleteFolder: function () {
+ return this.model.canDeleteFolder !== undefined ? this.model.canDeleteFolder : true;
}
},
mounted: function () {
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Media/Controllers/AdminController.cs
index 710af8c1616..7286ca87b59 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Controllers/AdminController.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Controllers/AdminController.cs
@@ -36,6 +36,7 @@ public class AdminController : Controller
private readonly IChunkFileUploadService _chunkFileUploadService;
private readonly IFileVersionProvider _fileVersionProvider;
private readonly IServiceProvider _serviceProvider;
+ private readonly AttachedMediaFieldFileService _attachedMediaFieldFileService;
public AdminController(
IMediaFileStore mediaFileStore,
@@ -48,7 +49,8 @@ public AdminController(
IUserAssetFolderNameProvider userAssetFolderNameProvider,
IChunkFileUploadService chunkFileUploadService,
IFileVersionProvider fileVersionProvider,
- IServiceProvider serviceProvider)
+ IServiceProvider serviceProvider,
+ AttachedMediaFieldFileService attachedMediaFieldFileService)
{
_mediaFileStore = mediaFileStore;
_mediaNameNormalizerService = mediaNameNormalizerService;
@@ -61,6 +63,7 @@ public AdminController(
_chunkFileUploadService = chunkFileUploadService;
_fileVersionProvider = fileVersionProvider;
_serviceProvider = serviceProvider;
+ _attachedMediaFieldFileService = attachedMediaFieldFileService;
}
[Admin("Media", "Media.Index")]
@@ -74,7 +77,7 @@ public async Task Index()
return View();
}
- public async Task>> GetFolders(string path)
+ public async Task>> GetFolders(string path)
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageMedia))
{
@@ -101,7 +104,21 @@ public async Task>> GetFolders(string
var allowed = _mediaFileStore.GetDirectoryContentAsync(path)
.WhereAwait(async e => e.IsDirectory && await _authorizationService.AuthorizeAsync(User, Permissions.ManageMediaFolder, (object)e.Path));
- return Ok(await allowed.ToListAsync());
+ return Ok(await allowed.Select(folder =>
+ {
+ var isSpecial = IsSpecialFolder(folder.Path);
+ return new MediaFolderViewModel()
+ {
+ Name = folder.Name,
+ Path = folder.Path,
+ DirectoryPath = folder.DirectoryPath,
+ IsDirectory = true,
+ LastModifiedUtc = folder.LastModifiedUtc,
+ Length = folder.Length,
+ CanCreateFolder = !isSpecial,
+ CanDeleteFolder = !isSpecial
+ };
+ }).ToListAsync());
}
public async Task>> GetMediaItems(string path, string extensions)
@@ -136,7 +153,8 @@ public async Task>> GetMediaItems(string path,
public async Task> GetMediaItem(string path)
{
- if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageMedia))
+ if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageMedia)
+ || (HttpContext.IsSecureMediaEnabled() && !await _authorizationService.AuthorizeAsync(User, SecureMediaPermissions.ViewMedia, (object)(path ?? string.Empty))))
{
return Forbid();
}
@@ -160,7 +178,8 @@ public async Task> GetMediaItem(string path)
[MediaSizeLimit]
public async Task Upload(string path, string extensions)
{
- if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageMedia))
+ if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageMedia)
+ || (HttpContext.IsSecureMediaEnabled() && !await _authorizationService.AuthorizeAsync(User, SecureMediaPermissions.ViewMedia, (object)(path ?? string.Empty))))
{
return Forbid();
}
@@ -308,7 +327,8 @@ public async Task DeleteMedia(string path)
public async Task MoveMedia(string oldPath, string newPath)
{
if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageMedia)
- || !await _authorizationService.AuthorizeAsync(User, Permissions.ManageMediaFolder, (object)oldPath))
+ || !await _authorizationService.AuthorizeAsync(User, Permissions.ManageMediaFolder, (object)oldPath)
+ || !await _authorizationService.AuthorizeAsync(User, Permissions.ManageMediaFolder, (object)newPath))
{
return Forbid();
}
@@ -482,8 +502,11 @@ public object CreateFileResult(IFileStoreEntry mediaFile)
};
}
- public IActionResult MediaApplication(MediaApplicationViewModel model)
+ public async Task MediaApplication(MediaApplicationViewModel model)
{
+ // Check if the user has access to new folders. If not, we hide the "create folder" button from the root folder.
+ model.AllowNewRootFolders = !HttpContext.IsSecureMediaEnabled() || await _authorizationService.AuthorizeAsync(User, SecureMediaPermissions.ViewMedia, (object)"_non-existent-path-87FD1922-8F88-4A33-9766-DA03E6E6F7BA");
+
return View(model);
}
@@ -553,5 +576,8 @@ private async Task PreCacheRemoteMedia(IFileStoreEntry mediaFile, Stream stream
localStream?.Dispose();
}
}
+
+ private bool IsSpecialFolder(string path)
+ => string.Equals(path, _mediaOptions.AssetsUsersFolder, StringComparison.OrdinalIgnoreCase) || string.Equals(path, _attachedMediaFieldFileService.MediaFieldsFolder, StringComparison.OrdinalIgnoreCase);
}
}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Drivers/MediaFieldDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Media/Drivers/MediaFieldDisplayDriver.cs
index 7ededc49906..0bf6e9bf6c1 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Drivers/MediaFieldDisplayDriver.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Drivers/MediaFieldDisplayDriver.cs
@@ -78,7 +78,7 @@ public override IDisplayResult Edit(MediaField field, BuildFieldEditorContext co
}
model.Paths = JConvert.SerializeObject(itemPaths, JOptions.CamelCase);
- model.TempUploadFolder = _attachedMediaFieldFileService.MediaFieldsTempSubFolder;
+ model.TempUploadFolder = _attachedMediaFieldFileService.GetMediaFieldsTempSubFolder();
model.Field = field;
model.Part = context.ContentPart;
model.PartFieldDefinition = context.PartFieldDefinition;
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Events/SecureMediaFileStoreEventHandler.cs b/src/OrchardCore.Modules/OrchardCore.Media/Events/SecureMediaFileStoreEventHandler.cs
new file mode 100644
index 00000000000..2283de53a95
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Events/SecureMediaFileStoreEventHandler.cs
@@ -0,0 +1,37 @@
+using System.Threading.Tasks;
+using OrchardCore.Environment.Cache;
+using OrchardCore.Media.Core.Events;
+
+namespace OrchardCore.Media.Events;
+
+internal sealed class SecureMediaFileStoreEventHandler : MediaEventHandlerBase
+{
+ private readonly ISignal _signal;
+
+ public SecureMediaFileStoreEventHandler(ISignal signal)
+ {
+ _signal = signal;
+ }
+
+ public override Task MediaCreatedDirectoryAsync(MediaCreatedContext context)
+ {
+ if (context.Result)
+ {
+ SignalDirectoryChange();
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public override Task MediaDeletedDirectoryAsync(MediaDeletedContext context)
+ {
+ if (context.Result)
+ {
+ SignalDirectoryChange();
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void SignalDirectoryChange() => _signal.DeferredSignalToken(nameof(SecureMediaPermissions));
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Media/Manifest.cs
index a38b240905e..408ff3693e0 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Manifest.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Manifest.cs
@@ -62,3 +62,14 @@
],
Category = "Content Management"
)]
+
+[assembly: Feature(
+ Id = "OrchardCore.Media.Security",
+ Name = "Secure Media",
+ Description = "Adds permissions to restrict access to media folders.",
+ Dependencies =
+ [
+ "OrchardCore.Media"
+ ],
+ Category = "Content Management"
+)]
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Processing/MediaImageSharpConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Media/Processing/MediaImageSharpConfiguration.cs
index e9341fcf037..711b0f4d54f 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Processing/MediaImageSharpConfiguration.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Processing/MediaImageSharpConfiguration.cs
@@ -2,6 +2,7 @@
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
+using OrchardCore.Media.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Web.Middleware;
using SixLabors.ImageSharp.Web.Processors;
@@ -83,6 +84,26 @@ public void Configure(ImageSharpMiddlewareOptions options)
return Task.CompletedTask;
};
+
+ var onPrepareResponse = options.OnPrepareResponseAsync;
+ options.OnPrepareResponseAsync = async context =>
+ {
+ if (onPrepareResponse is not null)
+ {
+ await onPrepareResponse(context);
+ }
+
+ // Override cache control for secure files
+ if (context.IsSecureMediaRequested())
+ {
+ var mediaOptions = context.RequestServices.GetRequiredService>().Value;
+ var secureCacheControl = mediaOptions.MaxSecureFilesBrowserCacheDays == 0
+ ? "no-store"
+ : "public, must-revalidate, max-age=" + TimeSpan.FromDays(mediaOptions.MaxSecureFilesBrowserCacheDays).TotalSeconds.ToString();
+
+ context.Response.Headers.CacheControl = secureCacheControl;
+ }
+ };
}
private static void ValidateTokenlessCommands(ImageCommandContext context, MediaOptions mediaOptions)
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/SecureMediaPermissions.cs b/src/OrchardCore.Modules/OrchardCore.Media/SecureMediaPermissions.cs
new file mode 100644
index 00000000000..6e958017d46
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Media/SecureMediaPermissions.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+using OrchardCore.Environment.Cache;
+using OrchardCore.Media.Services;
+using OrchardCore.Modules;
+using OrchardCore.Security.Permissions;
+
+namespace OrchardCore.Media
+{
+ [Feature("OrchardCore.Media.Security")]
+ public class SecureMediaPermissions : IPermissionProvider
+ {
+ // Note: The ManageMediaFolder permission grants all access, so viewing must be implied by it too.
+ public static readonly Permission ViewMedia = new("ViewMediaContent", "View media content in all folders", new[] { Permissions.ManageMediaFolder });
+ public static readonly Permission ViewRootMedia = new("ViewRootMediaContent", "View media content in the root folder", new[] { ViewMedia });
+ public static readonly Permission ViewOthersMedia = new("ViewOthersMediaContent", "View others media content", new[] { Permissions.ManageMediaFolder });
+ public static readonly Permission ViewOwnMedia = new("ViewOwnMediaContent", "View own media content", new[] { ViewOthersMedia });
+
+ private static readonly Permission _viewMediaTemplate = new("ViewMediaContent_{0}", "View media content in folder '{0}'", new[] { ViewMedia });
+
+ private static Dictionary, Permission> _permissionsByFolder = new();
+ private static readonly char[] _trimSecurePathChars = ['/', '\\', ' '];
+ private static readonly ReadOnlyDictionary _permissionTemplates = new(new Dictionary()
+ {
+ { ViewMedia.Name, _viewMediaTemplate },
+ });
+
+ private readonly MediaOptions _mediaOptions;
+ private readonly AttachedMediaFieldFileService _attachedMediaFieldFileService;
+ private readonly ISignal _signal;
+ private readonly IMediaFileStore _fileStore;
+ private readonly IMemoryCache _cache;
+
+ public SecureMediaPermissions(
+ IOptions options,
+ IMediaFileStore fileStore,
+ IMemoryCache cache,
+ AttachedMediaFieldFileService attachedMediaFieldFileService,
+ ISignal signal)
+ {
+ _mediaOptions = options.Value;
+ _fileStore = fileStore;
+ _cache = cache;
+ _attachedMediaFieldFileService = attachedMediaFieldFileService;
+ _signal = signal;
+ }
+
+ public async Task> GetPermissionsAsync()
+ {
+ return await _cache.GetOrCreateAsync(nameof(SecureMediaPermissions), async (entry) =>
+ {
+ // Ensure to rebuild at least after some time, to detect directory changes from outside of
+ // the media module. The signal gets set if a directory is created or deleted in the Media
+ // Library directly.
+ entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5))
+ .AddExpirationToken(_signal.GetToken(nameof(SecureMediaPermissions)));
+
+ return await GetPermissionsInternalAsync();
+ });
+ }
+
+ public IEnumerable GetDefaultStereotypes()
+ {
+ return new[]
+ {
+ new PermissionStereotype
+ {
+ Name = "Administrator",
+ Permissions = new[]
+ {
+ ViewMedia,
+ ViewOthersMedia
+ }
+ },
+ new PermissionStereotype
+ {
+ Name = "Authenticated",
+ Permissions = new[]
+ {
+ ViewOwnMedia
+ }
+ },
+ new PermissionStereotype
+ {
+ Name = "Anonymous",
+ Permissions = new[]
+ {
+ ViewMedia
+ }
+ }
+ };
+ }
+
+ ///
+ /// Returns a dynamic permission for a secure folder, based on a global view media permission template.
+ ///
+ internal static Permission ConvertToDynamicPermission(Permission permission) => _permissionTemplates.TryGetValue(permission.Name, out var result) ? result : null;
+
+ internal static Permission CreateDynamicPermission(Permission template, string secureFolder)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+
+ secureFolder = secureFolder?.Trim(_trimSecurePathChars);
+
+ var key = new ValueTuple(template.Name, secureFolder);
+
+ if (_permissionsByFolder.TryGetValue(key, out var permission))
+ {
+ return permission;
+ }
+
+ permission = new Permission(
+ string.Format(template.Name, secureFolder),
+ string.Format(template.Description, secureFolder),
+ (template.ImpliedBy ?? Array.Empty()).Select(t => CreateDynamicPermission(t, secureFolder))
+ );
+
+ var localPermissions = new Dictionary, Permission>(_permissionsByFolder)
+ {
+ [key] = permission,
+ };
+
+ _permissionsByFolder = localPermissions;
+
+ return permission;
+ }
+
+ private async Task> GetPermissionsInternalAsync()
+ {
+ // The ViewRootMedia permission must be implied by any subfolder permission.
+ var viewRootImpliedBy = new List(ViewRootMedia.ImpliedBy);
+ var result = new List()
+ {
+ ViewMedia,
+ new (ViewRootMedia.Name, ViewRootMedia.Description, viewRootImpliedBy),
+ ViewOthersMedia,
+ ViewOwnMedia
+ };
+
+ await foreach (var entry in _fileStore.GetDirectoryContentAsync())
+ {
+ if (!entry.IsDirectory)
+ continue;
+
+ if (entry.Name == _mediaOptions.AssetsUsersFolder ||
+ entry.Name == _attachedMediaFieldFileService.MediaFieldsFolder)
+ continue;
+
+ var folderPath = entry.Path;
+
+ foreach (var template in _permissionTemplates)
+ {
+ var dynamicPermission = CreateDynamicPermission(template.Value, folderPath);
+ result.Add(dynamicPermission);
+ viewRootImpliedBy.Add(dynamicPermission);
+ }
+ }
+
+ return result.AsEnumerable();
+ }
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Services/AttachedMediaFieldFileService.cs b/src/OrchardCore.Modules/OrchardCore.Media/Services/AttachedMediaFieldFileService.cs
index 7fc4981d69e..43e63d89616 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Services/AttachedMediaFieldFileService.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Services/AttachedMediaFieldFileService.cs
@@ -19,15 +19,18 @@ public class AttachedMediaFieldFileService
{
private readonly IMediaFileStore _fileStore;
private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly IUserAssetFolderNameProvider _userAssetFolderNameProvider;
private readonly ILogger _logger;
public AttachedMediaFieldFileService(
IMediaFileStore fileStore,
IHttpContextAccessor httpContextAccessor,
+ IUserAssetFolderNameProvider userAssetFolderNameProvider,
ILogger logger)
{
_fileStore = fileStore;
_httpContextAccessor = httpContextAccessor;
+ _userAssetFolderNameProvider = userAssetFolderNameProvider;
_logger = logger;
MediaFieldsFolder = "mediafields";
@@ -67,6 +70,13 @@ public async Task HandleFilesOnFieldUpdateAsync(List ite
await MoveNewFilesToContentItemDirAndUpdatePathsAsync(items, contentItem);
}
+ ///
+ /// Gets the per-user temporary upload directory.
+ ///
+ ///
+ public string GetMediaFieldsTempSubFolder()
+ => _fileStore.Combine(MediaFieldsTempSubFolder, _userAssetFolderNameProvider.GetUserAssetFolderName(_httpContextAccessor.HttpContext.User));
+
private async Task EnsureGlobalDirectoriesAsync()
{
await _fileStore.TryCreateDirectoryAsync(MediaFieldsFolder);
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Services/ManageMediaFolderAuthorizationHandler.cs b/src/OrchardCore.Modules/OrchardCore.Media/Services/ManageMediaFolderAuthorizationHandler.cs
index 13272151dda..55c936bc49c 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Services/ManageMediaFolderAuthorizationHandler.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Services/ManageMediaFolderAuthorizationHandler.cs
@@ -91,7 +91,12 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext
if (await authorizationService.AuthorizeAsync(context.User, permission))
{
- context.Succeed(requirement);
+ // Check if viewing is allowed for this folder, if secure media is also enabled.
+ if (!_serviceProvider.IsSecureMediaEnabled() ||
+ await authorizationService.AuthorizeAsync(context.User, SecureMediaPermissions.ViewMedia, (object)path))
+ {
+ context.Succeed(requirement);
+ }
}
}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Services/MediaOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Media/Services/MediaOptionsConfiguration.cs
index c0fceea468d..498f275e942 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Services/MediaOptionsConfiguration.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Services/MediaOptionsConfiguration.cs
@@ -4,7 +4,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
-using Microsoft.Net.Http.Headers;
using OrchardCore.Environment.Shell.Configuration;
namespace OrchardCore.Media.Services
@@ -54,6 +53,7 @@ public class MediaOptionsConfiguration : IConfigureOptions
];
private const int DefaultMaxBrowserCacheDays = 30;
+ private const int DefaultSecureFilesMaxBrowserCacheDays = 0;
private const int DefaultMaxCacheDays = 365;
private const int DefaultMaxFileSize = 30_000_000;
@@ -92,6 +92,7 @@ public void Configure(MediaOptions options)
StringComparer.OrdinalIgnoreCase);
options.MaxBrowserCacheDays = section.GetValue("MaxBrowserCacheDays", DefaultMaxBrowserCacheDays);
+ options.MaxSecureFilesBrowserCacheDays = section.GetValue("MaxSecureFilesBrowserCacheDays", DefaultSecureFilesMaxBrowserCacheDays);
options.MaxCacheDays = section.GetValue("MaxCacheDays", DefaultMaxCacheDays);
options.ResizedCacheMaxStale = section.GetValue(nameof(options.ResizedCacheMaxStale));
options.RemoteCacheMaxStale = section.GetValue(nameof(options.RemoteCacheMaxStale));
@@ -108,6 +109,10 @@ public void Configure(MediaOptions options)
// Use the same cache control header as ImageSharp does for resized images.
var cacheControl = "public, must-revalidate, max-age=" + TimeSpan.FromDays(options.MaxBrowserCacheDays).TotalSeconds.ToString();
+ // Secure files are not cached at all.
+ var secureCacheControl = options.MaxSecureFilesBrowserCacheDays == 0
+ ? "no-store"
+ : "public, must-revalidate, max-age=" + TimeSpan.FromDays(options.MaxSecureFilesBrowserCacheDays).TotalSeconds.ToString();
options.StaticFileOptions = new StaticFileOptions
{
@@ -115,8 +120,8 @@ public void Configure(MediaOptions options)
ServeUnknownFileTypes = true,
OnPrepareResponse = ctx =>
{
- ctx.Context.Response.Headers[HeaderNames.CacheControl] = cacheControl;
- ctx.Context.Response.Headers[HeaderNames.ContentSecurityPolicy] = contentSecurityPolicy;
+ ctx.Context.Response.Headers.CacheControl = ctx.Context.IsSecureMediaRequested() ? secureCacheControl : cacheControl;
+ ctx.Context.Response.Headers.ContentSecurityPolicy = contentSecurityPolicy;
}
};
}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaExtensions.cs
new file mode 100644
index 00000000000..89136080bd6
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaExtensions.cs
@@ -0,0 +1,22 @@
+using System;
+using Microsoft.AspNetCore.Http;
+
+namespace OrchardCore.Media.Services
+{
+ internal static class SecureMediaExtensions
+ {
+ private const string IsSecureMediaKey = "IsSecureMedia";
+
+ public static bool IsSecureMediaEnabled(this IServiceProvider serviceProvider)
+ => serviceProvider.GetService(typeof(SecureMediaMarker)) is not null;
+
+ public static bool IsSecureMediaEnabled(this HttpContext httpContext)
+ => httpContext.RequestServices.IsSecureMediaEnabled();
+
+ public static bool IsSecureMediaRequested(this HttpContext httpContext)
+ => httpContext.Items.ContainsKey(IsSecureMediaKey);
+
+ public static void MarkAsSecureMediaRequested(this HttpContext httpContext)
+ => httpContext.Items[IsSecureMediaKey] = bool.TrueString;
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaMarker.cs b/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaMarker.cs
new file mode 100644
index 00000000000..4e552bcfc01
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaMarker.cs
@@ -0,0 +1,4 @@
+namespace OrchardCore.Media.Services
+{
+ internal sealed class SecureMediaMarker { }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaMiddleware.cs b/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaMiddleware.cs
new file mode 100644
index 00000000000..8ceb738c472
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Services/SecureMediaMiddleware.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using OrchardCore.Routing;
+
+namespace OrchardCore.Media.Services
+{
+ public class SecureMediaMiddleware
+ {
+ private readonly RequestDelegate _next;
+ private readonly PathString _assetsRequestPath;
+
+ public SecureMediaMiddleware(
+ RequestDelegate next,
+ IOptions mediaOptions)
+ {
+ _next = next;
+ _assetsRequestPath = mediaOptions.Value.AssetsRequestPath;
+ }
+
+ public async Task Invoke(HttpContext context, IAuthorizationService authorizationService, IAuthenticationService authenticationService)
+ {
+ var validateAssetsRequestPath = context.Request.Path.StartsWithNormalizedSegments(_assetsRequestPath, StringComparison.OrdinalIgnoreCase, out var subPath);
+ if (!validateAssetsRequestPath)
+ {
+ await _next(context);
+
+ return;
+ }
+
+ if (!(context.User.Identity?.IsAuthenticated ?? false))
+ {
+ // Allow bearer (API) authentication too.
+ var authenticateResult = await authenticationService.AuthenticateAsync(context, "Api");
+
+ if (authenticateResult.Succeeded)
+ {
+ context.User = authenticateResult.Principal;
+ }
+ }
+
+ if (await authorizationService.AuthorizeAsync(context.User, SecureMediaPermissions.ViewMedia, (object)subPath.ToString()))
+ {
+ await _next(context);
+ }
+ else
+ {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ }
+ }
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Services/ViewMediaFolderAuthorizationHandler.cs b/src/OrchardCore.Modules/OrchardCore.Media/Services/ViewMediaFolderAuthorizationHandler.cs
new file mode 100644
index 00000000000..f5dfe038346
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Services/ViewMediaFolderAuthorizationHandler.cs
@@ -0,0 +1,231 @@
+using System;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using OrchardCore.ContentManagement;
+using OrchardCore.FileStorage;
+using OrchardCore.Security;
+using OrchardCore.Security.Permissions;
+
+namespace OrchardCore.Media.Services
+{
+ ///
+ /// Checks if the user has related permission to view media in the path resource which is passed from AuthorizationHandler.
+ ///
+ public class ViewMediaFolderAuthorizationHandler : AuthorizationHandler
+ {
+ private const char PathSeparator = '/';
+
+ private static readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
+
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly IContentManager _contentManager;
+ private readonly IMediaFileStore _fileStore;
+ private readonly IUserAssetFolderNameProvider _userAssetFolderNameProvider;
+ private readonly MediaOptions _mediaOptions;
+ private readonly string _mediaFieldsFolder;
+ private readonly string _usersFolder;
+
+ public ViewMediaFolderAuthorizationHandler(
+ IServiceProvider serviceProvider,
+ IHttpContextAccessor httpContextAccessor,
+ AttachedMediaFieldFileService attachedMediaFieldFileService,
+ IMediaFileStore fileStore,
+ IOptions options,
+ IUserAssetFolderNameProvider userAssetFolderNameProvider,
+ IContentManager contentManager)
+ {
+ _serviceProvider = serviceProvider;
+ _httpContextAccessor = httpContextAccessor;
+ _fileStore = fileStore;
+ _userAssetFolderNameProvider = userAssetFolderNameProvider;
+ _contentManager = contentManager;
+ _mediaOptions = options.Value;
+ _mediaFieldsFolder = EnsureTrailingSlash(attachedMediaFieldFileService.MediaFieldsFolder);
+ _usersFolder = EnsureTrailingSlash(_mediaOptions.AssetsUsersFolder);
+ }
+
+ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
+ {
+ if (context.HasSucceeded)
+ {
+ // This handler is not revoking any pre-existing grants.
+ return;
+ }
+
+ if (requirement.Permission.Name != SecureMediaPermissions.ViewMedia.Name)
+ {
+ return;
+ }
+
+ if (context.Resource is not string path)
+ {
+ return;
+ }
+
+ path = Uri.UnescapeDataString(path);
+
+ path = _fileStore.NormalizePath(path);
+
+ // Permissions are only set for the root and the first folder tier. Only for users and
+ // media fields we will check sub folders too.
+ var i = path.IndexOf(PathSeparator);
+ var folderPath = i >= 0 ? path[..i] : path;
+ var directory = await _fileStore.GetDirectoryInfoAsync(folderPath);
+ if (directory is null && path.IndexOf(PathSeparator, folderPath.Length) < 0)
+ {
+ // This could be a new directory, or a new or existing file in the root folder. As we cannot directly determine
+ // whether a file is uploaded or a new directory is created, we will check against the list of allowed extensions.
+ // If none is matched, we assume a new directory is created, otherwise we will check the root access only.
+ // Note: The file path is currently not authorized during upload, only the folder is checked. Therefore checking
+ // the file extensions is not actually required, but let's leave this in case we add an authorization call later.
+ if (await _fileStore.GetFileInfoAsync(folderPath) is not null ||
+ _mediaOptions.AllowedFileExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
+ {
+ path = string.Empty;
+ }
+ }
+
+ if (IsAuthorizedFolder("/", path))
+ {
+ await AuthorizeAsync(context, requirement, SecureMediaPermissions.ViewRootMedia);
+
+ return;
+ }
+
+ if (IsAuthorizedFolder(_mediaFieldsFolder, path) || IsDescendantOfAuthorizedFolder(_mediaFieldsFolder, path))
+ {
+ await AuthorizeAttachedMediaFieldsFolderAsync(context, requirement, path);
+
+ return;
+ }
+
+ if (IsAuthorizedFolder(_usersFolder, path) || IsDescendantOfAuthorizedFolder(_usersFolder, path))
+ {
+ await AuthorizeUsersFolderAsync(context, requirement, path);
+
+ return;
+ }
+
+ // Create a dynamic permission for the folder path. This allows to give access to a specific folders only.
+ var template = SecureMediaPermissions.ConvertToDynamicPermission(SecureMediaPermissions.ViewMedia);
+ if (template != null)
+ {
+ var permission = SecureMediaPermissions.CreateDynamicPermission(template, folderPath);
+ await AuthorizeAsync(context, requirement, permission);
+ }
+ else
+ {
+ // Not a secure file
+ context.Succeed(requirement);
+ }
+ }
+
+ private async Task AuthorizeAttachedMediaFieldsFolderAsync(AuthorizationHandlerContext context, PermissionRequirement requirement, string path)
+ {
+ var attachedMediaPathParts = path
+ .Substring(_mediaFieldsFolder.Length - 1)
+ .Split(PathSeparator, 3, StringSplitOptions.RemoveEmptyEntries);
+
+ // Don't allow 'mediafields' directly.
+ if (attachedMediaPathParts.Length == 0)
+ return;
+
+ if (string.Equals(attachedMediaPathParts[0], "temp", StringComparison.OrdinalIgnoreCase))
+ {
+ // Authorize per-user temporary files
+ var userId = attachedMediaPathParts.Length > 1 ? attachedMediaPathParts[1] : null;
+ var userAssetsFolderName = EnsureTrailingSlash(_userAssetFolderNameProvider.GetUserAssetFolderName(context.User));
+
+ if (IsAuthorizedFolder(userAssetsFolderName, userId))
+ {
+ await AuthorizeAsync(context, requirement, SecureMediaPermissions.ViewOwnMedia);
+ }
+ else
+ {
+ await AuthorizeAsync(context, requirement, SecureMediaPermissions.ViewOthersMedia);
+ }
+ }
+ else
+ {
+ // Authorize by using the content item permission. The user must have access to the content item to allow its media
+ // as well.
+ var contentItemId = attachedMediaPathParts.Length > 1 ? attachedMediaPathParts[1] : null;
+ var contentItem = !string.IsNullOrEmpty(contentItemId) ? await _contentManager.GetAsync(contentItemId) : null;
+
+ // Disallow if content item is not found or allowed
+ if (contentItem is not null)
+ {
+ await AuthorizeAsync(context, requirement, Contents.CommonPermissions.ViewContent, contentItem);
+ }
+ }
+ }
+
+ private async Task AuthorizeUsersFolderAsync(AuthorizationHandlerContext context, PermissionRequirement requirement, string path)
+ {
+ // We need to allow the _Users folder for own media access too. If someone uploads into this folder, we are screwed.
+ Permission permission;
+ if (path.IndexOf(PathSeparator) < 0)
+ {
+ permission = SecureMediaPermissions.ViewOwnMedia;
+ }
+ else
+ {
+ permission = SecureMediaPermissions.ViewOthersMedia;
+
+ var userFolderName = _userAssetFolderNameProvider.GetUserAssetFolderName(context.User);
+ if (!string.IsNullOrEmpty(userFolderName))
+ {
+ var userOwnFolder = EnsureTrailingSlash(_fileStore.Combine(_usersFolder, userFolderName));
+
+ if (IsAuthorizedFolder(userOwnFolder, path) || IsDescendantOfAuthorizedFolder(userOwnFolder, path))
+ {
+ permission = SecureMediaPermissions.ViewOwnMedia;
+ }
+ }
+ }
+
+ await AuthorizeAsync(context, requirement, permission);
+ }
+
+ private async Task AuthorizeAsync(AuthorizationHandlerContext context, PermissionRequirement requirement, Permission permission, object resource = null)
+ {
+ var authorizationService = _serviceProvider.GetService();
+ if (await authorizationService.AuthorizeAsync(context.User, permission, resource))
+ {
+ // If anonymous access is also possible, we want to use default browser caching policies.
+ // Otherwise we set a marker which causes a different caching policy being used.
+ if ((context.User.Identity?.IsAuthenticated ?? false) && !await authorizationService.AuthorizeAsync(_anonymous, permission, resource))
+ {
+ _httpContextAccessor.HttpContext.MarkAsSecureMediaRequested();
+ }
+
+ context.Succeed(requirement);
+ }
+ else
+ {
+ // Note: We don't want other authorization handlers to succeed the requirement. This would allow access to the
+ // users and attached media field folders, e.g. if the anonymous role has the "ViewMedia" permission set.
+ context.Fail(new AuthorizationFailureReason(this, "View media permission not granted"));
+ }
+ }
+
+ private static bool IsAuthorizedFolder(string authorizedFolder, string childPath)
+ {
+ // Ensure end trailing slash. childPath is already normalized.
+ childPath += PathSeparator;
+
+ return childPath.Equals(authorizedFolder, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsDescendantOfAuthorizedFolder(string authorizedFolder, string childPath)
+ => childPath.StartsWith(authorizedFolder, StringComparison.OrdinalIgnoreCase);
+
+ private string EnsureTrailingSlash(string path) => _fileStore.NormalizePath(path) + PathSeparator;
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs
index 01e4fd1da3a..4c04988f3b5 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs
@@ -201,6 +201,14 @@ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder ro
var mediaOptions = serviceProvider.GetRequiredService>().Value;
var mediaFileStoreCache = serviceProvider.GetService();
+ // Move middleware into SecureMediaStartup if it is possible to insert it between the users and media
+ // module. See issue https://github.com/OrchardCMS/OrchardCore/issues/15716.
+ // Secure media file middleware, but only if the feature is enabled.
+ if (serviceProvider.IsSecureMediaEnabled())
+ {
+ app.UseMiddleware();
+ }
+
// FileStore middleware before ImageSharp, but only if a remote storage module has registered a cache provider.
if (mediaFileStoreCache != null)
{
@@ -313,4 +321,18 @@ public override void ConfigureServices(IServiceCollection services)
});
}
}
+
+ [Feature("OrchardCore.Media.Security")]
+ public class SecureMediaStartup : StartupBase
+ {
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ // Marker service to easily detect if the feature has been enabled.
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddSingleton();
+ }
+ }
}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/ViewModels/MediaApplicationViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Media/ViewModels/MediaApplicationViewModel.cs
index 18644755a05..bbc3015e620 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/ViewModels/MediaApplicationViewModel.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/ViewModels/MediaApplicationViewModel.cs
@@ -3,4 +3,6 @@ namespace OrchardCore.Media.ViewModels;
public class MediaApplicationViewModel
{
public string Extensions { get; set; }
+
+ public bool AllowNewRootFolders { get; set; }
}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/ViewModels/MediaFolderViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Media/ViewModels/MediaFolderViewModel.cs
new file mode 100644
index 00000000000..29f749250d9
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Media/ViewModels/MediaFolderViewModel.cs
@@ -0,0 +1,22 @@
+using System;
+
+namespace OrchardCore.Media.ViewModels;
+
+public class MediaFolderViewModel
+{
+ public string Path { get; set; }
+
+ public string Name { get; set; }
+
+ public string DirectoryPath { get; set; }
+
+ public long Length { get; set; }
+
+ public DateTime LastModifiedUtc { get; set; }
+
+ public bool IsDirectory { get; set; }
+
+ public bool CanCreateFolder { get; set; }
+
+ public bool CanDeleteFolder { get; set; }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/MediaApplication.cshtml b/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/MediaApplication.cshtml
index 88d36d84b66..2437cb140da 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/MediaApplication.cshtml
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/MediaApplication.cshtml
@@ -127,6 +127,9 @@
+@* Settings *@
+
+
@* Chunked file upload settings *@
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/Options.cshtml b/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/Options.cshtml
index d7af4513ccd..77a9a3e158c 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/Options.cshtml
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Views/Admin/Options.cshtml
@@ -34,6 +34,14 @@
+
+
+
+
+ @T["The default number of days for the media cache control header for secure files."]
+
+
+
@@ -92,5 +100,5 @@
@T["The folder under AssetsPath used to store users own media assets."]
-
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/wwwroot/Scripts/media.js b/src/OrchardCore.Modules/OrchardCore.Media/wwwroot/Scripts/media.js
index bddbc52d838..e18e4388362 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/wwwroot/Scripts/media.js
+++ b/src/OrchardCore.Modules/OrchardCore.Media/wwwroot/Scripts/media.js
@@ -30,7 +30,8 @@ function initializeMediaApplication(displayMediaApplication, mediaApplicationUrl
name: $('#t-mediaLibrary').text(),
path: '',
folder: '',
- isDirectory: true
+ isDirectory: true,
+ canCreateFolder: $('#allowNewRootFolders').val() === 'true'
};
mediaApp = new Vue({
el: '#mediaApp',
@@ -545,7 +546,7 @@ function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _ty
function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
// component
Vue.component('folder', {
- template: "\n