diff --git a/Directory.Packages.props b/Directory.Packages.props
index e06af86a673..44f7093142e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -110,4 +110,8 @@
+
+
+
+
diff --git a/OrchardCore.slnx b/OrchardCore.slnx
index 8ed8126a7e8..de1a7ea611c 100644
--- a/OrchardCore.slnx
+++ b/OrchardCore.slnx
@@ -65,6 +65,7 @@
+
@@ -116,6 +117,7 @@
+
diff --git a/mkdocs.yml b/mkdocs.yml
index 456ab77a348..073938aa3d8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -209,6 +209,7 @@ nav:
- Media:
- Media: reference/modules/Media/README.md
- Media Slugify: reference/modules/Media.Slugify/README.md
+ - Antivirus: reference/modules/Antivirus/README.md
- Media Amazon S3: reference/modules/Media.AmazonS3/README.md
- Media Azure: reference/modules/Media.Azure/README.md
- ReCaptcha: reference/modules/ReCaptcha/README.md
@@ -252,6 +253,7 @@ nav:
- Audit Trail: reference/modules/AuditTrail/README.md
- Auto Setup: reference/modules/AutoSetup/README.md
- Features: reference/modules/Features/README.md
+ - File Upload Security: reference/core/file-upload-security.md
- Contents: reference/modules/Contents/README.md
- Configuration: reference/modules/Configuration/README.md
- Cors: reference/modules/Cors/README.md
diff --git a/src/OrchardCore.AspireHost/ClamAV.cs b/src/OrchardCore.AspireHost/ClamAV.cs
new file mode 100644
index 00000000000..f8f94131051
--- /dev/null
+++ b/src/OrchardCore.AspireHost/ClamAV.cs
@@ -0,0 +1,53 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+
+namespace OrchardCore.AspireHost;
+
+public sealed class ClamAVResource : ContainerResource, IResourceWithConnectionString
+{
+ internal const string PrimaryEndpointName = "tcp";
+
+ private EndpointReference _primaryEndpoint;
+
+ public ClamAVResource(string name)
+ : base(name)
+ {
+ }
+
+ public EndpointReference PrimaryEndpoint
+ => _primaryEndpoint ??= new EndpointReference(this, PrimaryEndpointName);
+
+ public ReferenceExpression ConnectionStringExpression
+ => ReferenceExpression.Create(
+ $"tcp://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}");
+}
+
+internal static class ClamAVResourceBuilderExtensions
+{
+ public static IResourceBuilder AddClamAV(
+ this IDistributedApplicationBuilder builder,
+ string name,
+ int? port = null)
+ {
+ var resource = new ClamAVResource(name);
+
+ return builder.AddResource(resource)
+ .WithImage("clamav/clamav")
+ .WithImageRegistry("docker.io")
+ .WithImageTag("latest")
+ .WithEnvironment("CLAMAV_NO_FRESHCLAMD", "true")
+ .WithEndpoint(port: port, name: ClamAVResource.PrimaryEndpointName, targetPort: 3310);
+ }
+
+ public static IResourceBuilder WithDataVolume(
+ this IResourceBuilder builder,
+ string name,
+ bool isReadOnly = false) =>
+ builder.WithVolume(name, "/var/lib/clamav", isReadOnly);
+
+ public static IResourceBuilder WithDataBindMount(
+ this IResourceBuilder builder,
+ string source,
+ bool isReadOnly = false) =>
+ builder.WithBindMount(source, "/var/lib/clamav", isReadOnly);
+}
diff --git a/src/OrchardCore.AspireHost/OrchardCore.AspireHost.csproj b/src/OrchardCore.AspireHost/OrchardCore.AspireHost.csproj
new file mode 100644
index 00000000000..0e2144694d9
--- /dev/null
+++ b/src/OrchardCore.AspireHost/OrchardCore.AspireHost.csproj
@@ -0,0 +1,22 @@
+
+
+
+ $(DefaultTargetFramework)
+ Exe
+ true
+ false
+ 53f65258-c4bc-45d0-ab79-c8309ff77904
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrchardCore.AspireHost/Program.cs b/src/OrchardCore.AspireHost/Program.cs
new file mode 100644
index 00000000000..2789ab5058d
--- /dev/null
+++ b/src/OrchardCore.AspireHost/Program.cs
@@ -0,0 +1,20 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using OrchardCore.AspireHost;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var clamAv = builder.AddClamAV("antivirus")
+ .WithDataVolume("clamavdb");
+
+builder.AddProject("OrchardCoreCms")
+ .WithExternalHttpEndpoints()
+ .WithReference(clamAv)
+ .WithEnvironment("OrchardCore__Antivirus_ClamAV__Host", clamAv.Resource.PrimaryEndpoint.Property(EndpointProperty.Host))
+ .WithEnvironment("OrchardCore__Antivirus_ClamAV__Port", clamAv.Resource.PrimaryEndpoint.Property(EndpointProperty.Port))
+ .WithEnvironment("OrchardCore__Antivirus_ClamAV__ConnectTimeoutSeconds", "5")
+ .WithEnvironment("OrchardCore__Antivirus_ClamAV__TransferTimeoutSeconds", "30");
+
+var app = builder.Build();
+
+await app.RunAsync();
diff --git a/src/OrchardCore.AspireHost/Properties/launchSettings.json b/src/OrchardCore.AspireHost/Properties/launchSettings.json
new file mode 100644
index 00000000000..bf2f5b9ee03
--- /dev/null
+++ b/src/OrchardCore.AspireHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:17262;http://localhost:15209",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21196",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22006"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:15209",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19032",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20030"
+ }
+ }
+ }
+}
diff --git a/src/OrchardCore.AspireHost/appsettings.Development.json b/src/OrchardCore.AspireHost/appsettings.Development.json
new file mode 100644
index 00000000000..31c092aa450
--- /dev/null
+++ b/src/OrchardCore.AspireHost/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/src/OrchardCore.AspireHost/appsettings.json b/src/OrchardCore.AspireHost/appsettings.json
new file mode 100644
index 00000000000..0c208ae9181
--- /dev/null
+++ b/src/OrchardCore.AspireHost/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/OrchardCore.AspireHost/aspire.config.json b/src/OrchardCore.AspireHost/aspire.config.json
new file mode 100644
index 00000000000..da58ac9e4c9
--- /dev/null
+++ b/src/OrchardCore.AspireHost/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "OrchardCore.AspireHost.csproj"
+ }
+}
diff --git a/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj b/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj
index fc449c0160b..9a6cd034486 100644
--- a/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj
+++ b/src/OrchardCore.Cms.Web/OrchardCore.Cms.Web.csproj
@@ -1,4 +1,4 @@
-
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAVOptions.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAVOptions.cs
new file mode 100644
index 00000000000..fdd3e9d7686
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAVOptions.cs
@@ -0,0 +1,14 @@
+namespace OrchardCore.Antivirus.ClamAV;
+
+public sealed class ClamAvOptions
+{
+ public const string ConfigSection = "Antivirus_ClamAV";
+
+ public string Host { get; set; }
+
+ public int Port { get; set; } = 3310;
+
+ public int ConnectTimeoutSeconds { get; set; } = 5;
+
+ public int TransferTimeoutSeconds { get; set; } = 30;
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnection.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnection.cs
new file mode 100644
index 00000000000..0c5e24926a6
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnection.cs
@@ -0,0 +1,132 @@
+using System.Net.Sockets;
+using System.Text;
+using Microsoft.Extensions.Logging;
+
+namespace OrchardCore.Antivirus.ClamAV;
+
+public sealed class ClamAvConnection : IDisposable
+{
+ private const int BufferSize = 81920;
+ private static readonly byte[] _scanCommand = "nINSTREAM\n"u8.ToArray();
+
+ private readonly ClamAvOptions _options;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+
+ private TcpClient _client;
+ private NetworkStream _networkStream;
+
+ public ClamAvConnection(ClamAvOptions options, ILogger logger)
+ {
+ _options = options;
+ _logger = logger;
+ }
+
+ public async Task ScanAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ await _lock.WaitAsync(cancellationToken);
+
+ try
+ {
+ try
+ {
+ await EnsureConnectedAsync(cancellationToken);
+
+ using var transferCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ transferCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(_options.TransferTimeoutSeconds));
+
+ await _networkStream.WriteAsync(_scanCommand, transferCancellationTokenSource.Token);
+
+ var buffer = new byte[BufferSize];
+ int bytesRead;
+
+ while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), transferCancellationTokenSource.Token)) > 0)
+ {
+ var prefix = BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(bytesRead));
+
+ await _networkStream.WriteAsync(prefix, transferCancellationTokenSource.Token);
+ await _networkStream.WriteAsync(buffer.AsMemory(0, bytesRead), transferCancellationTokenSource.Token);
+ }
+
+ await _networkStream.WriteAsync(new byte[sizeof(int)], transferCancellationTokenSource.Token);
+ await _networkStream.FlushAsync(transferCancellationTokenSource.Token);
+
+ return await ReadResponseAsync(_networkStream, transferCancellationTokenSource.Token);
+ }
+ catch (Exception exception) when (
+ exception is IOException or
+ SocketException or
+ OperationCanceledException or
+ ObjectDisposedException)
+ {
+ ResetConnection();
+ throw;
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public void Dispose()
+ {
+ ResetConnection();
+ _lock.Dispose();
+ }
+
+ private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
+ {
+ if (_client?.Connected == true && _networkStream is not null)
+ {
+ return;
+ }
+
+ ResetConnection();
+
+ var client = new TcpClient();
+ using var connectCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ connectCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
+
+ await client.ConnectAsync(_options.Host, _options.Port, connectCancellationTokenSource.Token);
+
+ client.SendTimeout = (int)TimeSpan.FromSeconds(_options.TransferTimeoutSeconds).TotalMilliseconds;
+ client.ReceiveTimeout = (int)TimeSpan.FromSeconds(_options.TransferTimeoutSeconds).TotalMilliseconds;
+
+ _client = client;
+ _networkStream = client.GetStream();
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Created a shared ClamAV TCP connection for '{Host}:{Port}'.", _options.Host, _options.Port);
+ }
+ }
+
+ private void ResetConnection()
+ {
+ _networkStream?.Dispose();
+ _networkStream = null;
+ _client?.Dispose();
+ _client = null;
+ }
+
+ private static async Task ReadResponseAsync(NetworkStream stream, CancellationToken cancellationToken)
+ {
+ using var responseStream = new MemoryStream();
+ var buffer = new byte[1];
+
+ while (true)
+ {
+ var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 1), cancellationToken);
+
+ if (bytesRead == 0 || buffer[0] == '\n')
+ {
+ break;
+ }
+
+ responseStream.WriteByte(buffer[0]);
+ }
+
+ return Encoding.ASCII.GetString(responseStream.ToArray());
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnectionFactory.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnectionFactory.cs
new file mode 100644
index 00000000000..3505b687318
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvConnectionFactory.cs
@@ -0,0 +1,64 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace OrchardCore.Antivirus.ClamAV;
+
+public sealed class ClamAvConnectionFactory : IDisposable
+{
+ private static readonly ConcurrentDictionary> _connections = new();
+ private static volatile int _registered;
+ private static volatile int _refCount;
+
+ private readonly IHostApplicationLifetime _lifetime;
+ private readonly ILoggerFactory _loggerFactory;
+
+ public ClamAvConnectionFactory(
+ IHostApplicationLifetime lifetime,
+ ILoggerFactory loggerFactory)
+ {
+ Interlocked.Increment(ref _refCount);
+
+ _lifetime = lifetime;
+ _loggerFactory = loggerFactory;
+
+ if (Interlocked.CompareExchange(ref _registered, 1, 0) == 0)
+ {
+ _lifetime.ApplicationStopped.Register(Release);
+ }
+ }
+
+ public ClamAvConnection Create(ClamAvOptions options)
+ {
+ var key = $"{options.Host}:{options.Port}:{options.ConnectTimeoutSeconds}:{options.TransferTimeoutSeconds}";
+
+ return _connections.GetOrAdd(key, _ => new Lazy(() =>
+ new ClamAvConnection(options, _loggerFactory.CreateLogger()))).Value;
+ }
+
+ public void Dispose()
+ {
+ if (Interlocked.Decrement(ref _refCount) == 0 && _lifetime.ApplicationStopped.IsCancellationRequested)
+ {
+ Release();
+ }
+ }
+
+ internal static void Release()
+ {
+ if (Interlocked.CompareExchange(ref _refCount, 0, 0) == 0)
+ {
+ var connections = _connections.Values.ToArray();
+
+ _connections.Clear();
+
+ foreach (var connection in connections)
+ {
+ if (connection.IsValueCreated)
+ {
+ connection.Value.Dispose();
+ }
+ }
+ }
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvFileEventHandler.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvFileEventHandler.cs
new file mode 100644
index 00000000000..a59bf11504b
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/ClamAV/ClamAvFileEventHandler.cs
@@ -0,0 +1,179 @@
+using System.Net.Sockets;
+using Microsoft.Extensions.Localization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using OrchardCore.FileStorage;
+using OrchardCore.Infrastructure;
+
+namespace OrchardCore.Antivirus.ClamAV;
+
+public sealed class ClamAvFileEventHandler : IFileEventHandler
+{
+ private readonly ClamAvOptions _options;
+ private readonly ClamAvConnectionFactory _connectionFactory;
+ private readonly ILogger _logger;
+
+ public ClamAvFileEventHandler(
+ IOptions options,
+ ClamAvConnectionFactory connectionFactory,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _connectionFactory = connectionFactory;
+ _logger = logger;
+ }
+
+ public async Task CreatingAsync(FileCreatingContext context, Stream stream, CancellationToken cancellationToken = default)
+ {
+ ValidateOptions();
+
+ var scanStream = stream;
+
+ if (scanStream.CanSeek)
+ {
+ scanStream.Position = 0;
+ }
+ else
+ {
+ scanStream = await CreateSeekableStreamAsync(scanStream, cancellationToken);
+ }
+
+ try
+ {
+ var response = await _connectionFactory.Create(_options).ScanAsync(scanStream, cancellationToken);
+
+ if (TryCreateFailureResult(context, scanStream, response) is { } failureResult)
+ {
+ return failureResult;
+ }
+
+ scanStream.Position = 0;
+
+ return FileCreatingResult.Success(scanStream);
+ }
+ catch (OperationCanceledException exception)
+ {
+ if (scanStream != stream)
+ {
+ await scanStream.DisposeAsync();
+ }
+
+ _logger.LogError(exception, "ClamAV timed out while scanning '{FileName}'.", context.FileName);
+
+ throw new AntivirusScanningException($"The ClamAV antivirus scanner timed out while scanning '{context.FileName}'.", exception);
+ }
+ catch (SocketException exception)
+ {
+ if (scanStream != stream)
+ {
+ await scanStream.DisposeAsync();
+ }
+
+ _logger.LogError(exception, "ClamAV could not be reached while scanning '{FileName}'.", context.FileName);
+
+ throw new AntivirusScanningException($"The ClamAV antivirus scanner could not be reached while scanning '{context.FileName}'.", exception);
+ }
+ catch (IOException exception)
+ {
+ if (scanStream != stream)
+ {
+ await scanStream.DisposeAsync();
+ }
+
+ _logger.LogError(exception, "ClamAV failed while scanning '{FileName}'.", context.FileName);
+
+ throw new AntivirusScanningException($"The ClamAV antivirus scanner failed while scanning '{context.FileName}'.", exception);
+ }
+ catch
+ {
+ if (scanStream != stream)
+ {
+ await scanStream.DisposeAsync();
+ }
+
+ throw;
+ }
+ }
+
+ public Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+
+ private static async Task CreateSeekableStreamAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ var tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ var tempStream = new FileStream(
+ tempFilePath,
+ FileMode.CreateNew,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ 81920,
+ FileOptions.Asynchronous | FileOptions.DeleteOnClose);
+
+ try
+ {
+ await stream.CopyToAsync(tempStream, cancellationToken);
+ tempStream.Position = 0;
+
+ return tempStream;
+ }
+ catch
+ {
+ await tempStream.DisposeAsync();
+ throw;
+ }
+ }
+
+ private static FileCreatingResult TryCreateFailureResult(FileCreatingContext context, Stream stream, string response)
+ {
+ if (string.Equals(response, "stream: OK", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ if (response.EndsWith(" FOUND", StringComparison.Ordinal))
+ {
+ var signature = response;
+ var separatorIndex = signature.IndexOf(": ", StringComparison.Ordinal);
+
+ if (separatorIndex >= 0)
+ {
+ signature = signature[(separatorIndex + 2)..];
+ }
+
+ signature = signature[..^" FOUND".Length];
+
+ stream.Position = 0;
+
+ return FileCreatingResult.Failed(stream, new ResultError
+ {
+ Message = new LocalizedString(nameof(ClamAvFileEventHandler), $"The uploaded file '{context.FileName}' was rejected because ClamAV detected '{signature}'."),
+ });
+ }
+
+ throw new AntivirusScanningException(
+ $"The ClamAV antivirus scanner returned an unexpected response while scanning '{context.FileName}': {response}");
+ }
+
+ private void ValidateOptions()
+ {
+ if (string.IsNullOrWhiteSpace(_options.Host))
+ {
+ throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the host setting is missing.");
+ }
+
+ if (_options.Port is < 1 or > 65535)
+ {
+ throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the port setting is invalid.");
+ }
+
+ if (_options.ConnectTimeoutSeconds <= 0)
+ {
+ throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the connection timeout must be greater than zero.");
+ }
+
+ if (_options.TransferTimeoutSeconds <= 0)
+ {
+ throw new AntivirusScanningException("The ClamAV antivirus scanner is enabled but the transfer timeout must be greater than zero.");
+ }
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/Manifest.cs
new file mode 100644
index 00000000000..76b0a84570f
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/Manifest.cs
@@ -0,0 +1,15 @@
+using OrchardCore.Modules.Manifest;
+
+[assembly: Module(
+ Name = "ClamAV Antivirus Scanner",
+ Author = ManifestConstants.OrchardCoreTeam,
+ Website = ManifestConstants.OrchardCoreWebsite,
+ Version = ManifestConstants.OrchardCoreVersion
+)]
+
+[assembly: Feature(
+ Id = "OrchardCore.Antivirus.ClamAV",
+ Name = "ClamAV Antivirus Scanner",
+ Description = "Scans files with ClamAV before Orchard Core stores them.",
+ Category = "Security"
+)]
diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/OrchardCore.Antivirus.csproj b/src/OrchardCore.Modules/OrchardCore.Antivirus/OrchardCore.Antivirus.csproj
new file mode 100644
index 00000000000..df290d1277a
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/OrchardCore.Antivirus.csproj
@@ -0,0 +1,21 @@
+
+
+
+ true
+ OrchardCore Antivirus ClamAV
+ $(OCCMSDescription)
+
+ Provides a ClamAV-backed antivirus scanner for Orchard Core uploads.
+ $(PackageTags) OrchardCoreCMS ContentManagement Security
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Antivirus/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Antivirus/Startup.cs
new file mode 100644
index 00000000000..2becf57d075
--- /dev/null
+++ b/src/OrchardCore.Modules/OrchardCore.Antivirus/Startup.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using OrchardCore.Antivirus.ClamAV;
+using OrchardCore.Environment.Shell.Configuration;
+using OrchardCore.FileStorage;
+using OrchardCore.Modules;
+
+namespace OrchardCore.Antivirus;
+
+public sealed class Startup : StartupBase
+{
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ }
+}
+
+[Feature("OrchardCore.Antivirus.ClamAV")]
+public sealed class ClamAVStartup : StartupBase
+{
+ private readonly IShellConfiguration _configuration;
+
+ public ClamAVStartup(IShellConfiguration configuration)
+ {
+ _configuration = configuration;
+ }
+
+ public override void ConfigureServices(IServiceCollection services)
+ {
+ services.Configure(_configuration.GetSection(ClamAvOptions.ConfigSection));
+ services.TryAddSingleton();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ }
+}
diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs
index e9f1e35401e..db05a20ff89 100644
--- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Controllers/ImportRemoteInstanceController.cs
@@ -11,6 +11,7 @@
using OrchardCore.Deployment.Remote.ViewModels;
using OrchardCore.Deployment.Services;
using OrchardCore.DisplayManagement.Notify;
+using OrchardCore.FileStorage;
using OrchardCore.Recipes.Models;
namespace OrchardCore.Deployment.Remote.Controllers;
@@ -22,6 +23,7 @@ public sealed class ImportRemoteInstanceController : Controller
private readonly INotifier _notifier;
private readonly ILogger _logger;
private readonly IDataProtector _dataProtector;
+ private readonly FileCreationService _fileCreationService;
internal readonly IHtmlLocalizer H;
internal readonly IStringLocalizer S;
@@ -30,12 +32,14 @@ public ImportRemoteInstanceController(
IDataProtectionProvider dataProtectionProvider,
RemoteClientService remoteClientService,
IDeploymentManager deploymentManager,
+ FileCreationService fileCreationService,
INotifier notifier,
IHtmlLocalizer htmlLocalizer,
IStringLocalizer stringLocalizer,
ILogger logger)
{
_deploymentManager = deploymentManager;
+ _fileCreationService = fileCreationService;
_notifier = notifier;
_logger = logger;
_remoteClientService = remoteClientService;
@@ -76,9 +80,20 @@ public async Task Import(ImportViewModel model)
try
{
- using (var fs = System.IO.File.Create(tempArchiveName))
+ await using var uploadedStream = model.Content.OpenReadStream();
+ await using var fileCreatingResult = await _fileCreationService.CreateAsync(
+ new FileCreatingContext(model.Content.FileName, model.Content.Length, model.Content.ContentType),
+ uploadedStream,
+ HttpContext.RequestAborted);
+
+ if (!fileCreatingResult.Succeeded)
+ {
+ return StatusCode((int)HttpStatusCode.BadRequest, fileCreatingResult.ErrorMessage ?? $"The uploaded file '{model.Content.FileName}' was rejected.");
+ }
+
+ await using (var fs = System.IO.File.Create(tempArchiveName))
{
- await model.Content.CopyToAsync(fs);
+ await fileCreatingResult.Stream.CopyToAsync(fs, HttpContext.RequestAborted);
}
ZipFile.ExtractToDirectory(tempArchiveName, tempArchiveFolder);
diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj
index 8c1a77018ce..ac939193b4f 100644
--- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj
+++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/OrchardCore.Deployment.Remote.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs
index b4eb3c0152d..5eb35ca29de 100644
--- a/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Deployment.Remote/Startup.cs
@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using OrchardCore.Deployment.Remote;
using OrchardCore.Deployment.Remote.Services;
+using OrchardCore.FileStorage;
using OrchardCore.Modules;
using OrchardCore.Navigation;
using OrchardCore.Security.Permissions;
@@ -12,6 +14,7 @@ public sealed class Startup : StartupBase
public override void ConfigureServices(IServiceCollection services)
{
services.AddHttpClient();
+ services.TryAddSingleton();
services.AddNavigationProvider();
services.AddScoped();
diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs b/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs
index 14c4560fa5f..314a8909c47 100644
--- a/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Deployment/Controllers/ImportController.cs
@@ -11,6 +11,7 @@
using OrchardCore.Deployment.Services;
using OrchardCore.Deployment.ViewModels;
using OrchardCore.DisplayManagement.Notify;
+using OrchardCore.FileStorage;
using OrchardCore.Mvc.Utilities;
using OrchardCore.Recipes.Models;
@@ -23,6 +24,7 @@ public sealed class ImportController : Controller
private readonly IAuthorizationService _authorizationService;
private readonly INotifier _notifier;
private readonly ILogger _logger;
+ private readonly FileCreationService _fileCreationService;
internal readonly IHtmlLocalizer H;
internal readonly IStringLocalizer S;
@@ -30,6 +32,7 @@ public sealed class ImportController : Controller
public ImportController(
IDeploymentManager deploymentManager,
IAuthorizationService authorizationService,
+ FileCreationService fileCreationService,
INotifier notifier,
ILogger logger,
IHtmlLocalizer htmlLocalizer,
@@ -38,6 +41,7 @@ IStringLocalizer stringLocalizer
{
_deploymentManager = deploymentManager;
_authorizationService = authorizationService;
+ _fileCreationService = fileCreationService;
_notifier = notifier;
_logger = logger;
H = htmlLocalizer;
@@ -69,9 +73,22 @@ public async Task Import(IFormFile importedPackage)
try
{
- using (var stream = new FileStream(tempArchiveName, FileMode.Create))
+ await using var uploadedStream = importedPackage.OpenReadStream();
+ await using var fileCreatingResult = await _fileCreationService.CreateAsync(
+ new FileCreatingContext(importedPackage.FileName, importedPackage.Length, importedPackage.ContentType),
+ uploadedStream,
+ HttpContext.RequestAborted);
+
+ if (!fileCreatingResult.Succeeded)
+ {
+ await _notifier.ErrorAsync(H[fileCreatingResult.ErrorMessage ?? $"The uploaded file '{importedPackage.FileName}' was rejected."]);
+
+ return RedirectToAction(nameof(Index));
+ }
+
+ await using (var stream = new FileStream(tempArchiveName, FileMode.Create))
{
- await importedPackage.CopyToAsync(stream);
+ await fileCreatingResult.Stream.CopyToAsync(stream, HttpContext.RequestAborted);
}
if (importedPackage.FileName.EndsWith(".zip"))
diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj b/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj
index 6fcc560bfc0..7baacaaf38b 100644
--- a/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj
+++ b/src/OrchardCore.Modules/OrchardCore.Deployment/OrchardCore.Deployment.csproj
@@ -20,6 +20,7 @@
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs
index 7911c0cb69c..29c0dbf1ee6 100644
--- a/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Deployment/Startup.cs
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using OrchardCore.Data;
using OrchardCore.Data.Migration;
using OrchardCore.Deployment.Core;
@@ -9,6 +10,7 @@
using OrchardCore.Deployment.Recipes;
using OrchardCore.Deployment.Steps;
using OrchardCore.DisplayManagement.Handlers;
+using OrchardCore.FileStorage;
using OrchardCore.Modules;
using OrchardCore.Navigation;
using OrchardCore.Recipes;
@@ -20,6 +22,7 @@ public sealed class Startup : StartupBase
{
public override void ConfigureServices(IServiceCollection services)
{
+ services.TryAddSingleton();
services.AddDeploymentServices();
services.AddNavigationProvider();
diff --git a/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs
index 57bc1aa610e..9e9fa81adac 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media.AmazonS3/Startup.cs
@@ -103,6 +103,7 @@ public override void ConfigureServices(IServiceCollection services)
var mediaOptions = serviceProvider.GetRequiredService>().Value;
var mediaEventHandlers = serviceProvider.GetServices();
var mediaCreatingEventHandlers = serviceProvider.GetServices();
+ var fileCreationService = serviceProvider.GetRequiredService();
var clock = serviceProvider.GetRequiredService();
var logger = serviceProvider.GetRequiredService>();
var amazonS3Client = serviceProvider.GetService();
@@ -126,6 +127,7 @@ public override void ConfigureServices(IServiceCollection services)
mediaOptions.CdnBaseUrl,
mediaEventHandlers,
mediaCreatingEventHandlers,
+ fileCreationService,
logger);
}));
diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs
index 903d45f22f3..26b209dc41a 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs
@@ -95,6 +95,7 @@ public override void ConfigureServices(IServiceCollection services)
var contentTypeProvider = serviceProvider.GetRequiredService();
var mediaEventHandlers = serviceProvider.GetServices();
var mediaCreatingEventHandlers = serviceProvider.GetServices();
+ var fileCreationService = serviceProvider.GetRequiredService();
var logger = serviceProvider.GetRequiredService>();
var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider);
@@ -109,7 +110,7 @@ public override void ConfigureServices(IServiceCollection services)
mediaUrlBase = fileStore.Combine(originalPathBase.Value, mediaUrlBase);
}
- return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, logger);
+ return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, fileCreationService, logger);
}));
services.AddSingleton();
diff --git a/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs
index bc51125e5f4..77ca434566a 100644
--- a/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs
+++ b/src/OrchardCore.Modules/OrchardCore.Media/Startup.cs
@@ -87,6 +87,7 @@ public override void ConfigureServices(IServiceCollection services)
services.AddResourceConfiguration();
services.AddTransient, MediaOptionsConfiguration>();
+ services.TryAddSingleton();
services.AddSingleton(serviceProvider =>
{
@@ -115,6 +116,7 @@ public override void ConfigureServices(IServiceCollection services)
var mediaOptions = serviceProvider.GetRequiredService>().Value;
var mediaEventHandlers = serviceProvider.GetServices();
var mediaCreatingEventHandlers = serviceProvider.GetServices();
+ var fileCreationService = serviceProvider.GetRequiredService();
var fileSystemStoreLogger = serviceProvider.GetRequiredService>();
var defaultMediaFileStoreLogger = serviceProvider.GetRequiredService>();
@@ -132,7 +134,7 @@ public override void ConfigureServices(IServiceCollection services)
mediaUrlBase = fileStore.Combine(originalPathBase.Value, mediaUrlBase);
}
- return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, defaultMediaFileStoreLogger);
+ return new DefaultMediaFileStore(fileStore, mediaUrlBase, mediaOptions.CdnBaseUrl, mediaEventHandlers, mediaCreatingEventHandlers, fileCreationService, defaultMediaFileStoreLogger);
});
services.AddPermissionProvider();
diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj
index 4b7a20af08a..f3c19af713e 100644
--- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj
+++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj
@@ -41,6 +41,7 @@
+
diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/AntivirusScanningException.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/AntivirusScanningException.cs
new file mode 100644
index 00000000000..c48e43168d6
--- /dev/null
+++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/AntivirusScanningException.cs
@@ -0,0 +1,14 @@
+namespace OrchardCore.FileStorage;
+
+public class AntivirusScanningException : FileStoreException
+{
+ public AntivirusScanningException(string message)
+ : base(message)
+ {
+ }
+
+ public AntivirusScanningException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingContext.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingContext.cs
new file mode 100644
index 00000000000..a2f073c5f33
--- /dev/null
+++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingContext.cs
@@ -0,0 +1,19 @@
+namespace OrchardCore.FileStorage;
+
+public sealed class FileCreatingContext
+{
+ public FileCreatingContext(string path, long? length = null, string contentType = null)
+ {
+ Path = path;
+ Length = length;
+ ContentType = contentType;
+ }
+
+ public string Path { get; set; }
+
+ public string FileName => System.IO.Path.GetFileName(Path);
+
+ public long? Length { get; set; }
+
+ public string ContentType { get; set; }
+}
diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingResult.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingResult.cs
new file mode 100644
index 00000000000..98c28dbb7e7
--- /dev/null
+++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreatingResult.cs
@@ -0,0 +1,55 @@
+using OrchardCore.Infrastructure;
+
+namespace OrchardCore.FileStorage;
+
+///
+/// Represents the outcome of processing a file before it is stored.
+///
+public sealed class FileCreatingResult : Result, IAsyncDisposable
+{
+ private readonly bool _ownsStream;
+
+ private FileCreatingResult(Stream stream, bool ownsStream)
+ : base(true)
+ {
+ Stream = stream;
+ _ownsStream = ownsStream;
+ }
+
+ private FileCreatingResult(Stream stream, bool ownsStream, IEnumerable errors)
+ : base(errors)
+ {
+ Stream = stream;
+ _ownsStream = ownsStream;
+ }
+
+ ///
+ /// Gets the stream that should continue through the upload pipeline.
+ ///
+ public Stream Stream { get; }
+
+ public string ErrorMessage
+ => Errors
+ .Select(error => error.Message?.Value)
+ .FirstOrDefault(message => !string.IsNullOrWhiteSpace(message));
+
+ public static FileCreatingResult Success(Stream stream)
+ => new(stream, false);
+
+ public static FileCreatingResult Failed(Stream stream = null, params ResultError[] errors)
+ => new(stream, false, errors);
+
+ internal static FileCreatingResult Create(Stream stream, bool ownsStream, IEnumerable errors)
+ => new(stream, ownsStream, errors);
+
+ internal static FileCreatingResult Create(Stream stream, bool ownsStream)
+ => new(stream, ownsStream);
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_ownsStream && Stream is not null)
+ {
+ await Stream.DisposeAsync();
+ }
+ }
+}
diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreationService.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreationService.cs
new file mode 100644
index 00000000000..e1ada6d0e38
--- /dev/null
+++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/FileCreationService.cs
@@ -0,0 +1,85 @@
+namespace OrchardCore.FileStorage;
+
+///
+/// Coordinates instances for user-uploaded files.
+///
+public sealed class FileCreationService
+{
+ private readonly IEnumerable _handlers;
+
+ public FileCreationService(IEnumerable handlers)
+ {
+ _handlers = handlers;
+ }
+
+ ///
+ /// Runs the pre-create pipeline and returns the stream that should be stored.
+ /// The caller owns the original and should dispose the returned
+ /// to clean up any replacement stream created by handlers.
+ ///
+ public async Task CreateAsync(
+ FileCreatingContext context,
+ Stream stream,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(stream);
+
+ var currentStream = stream;
+
+ try
+ {
+ foreach (var handler in _handlers)
+ {
+ var creatingStream = currentStream;
+ var result = await handler.CreatingAsync(context, creatingStream, cancellationToken);
+
+ if (result is null)
+ {
+ throw new InvalidOperationException($"{handler.GetType().Name} returned a null {nameof(FileCreatingResult)}.");
+ }
+
+ currentStream = result.Stream ?? creatingStream;
+
+ if (creatingStream != currentStream && creatingStream != stream)
+ {
+ await creatingStream.DisposeAsync();
+ }
+
+ if (!result.Succeeded)
+ {
+ return FileCreatingResult.Create(currentStream, currentStream != stream, result.Errors.ToList());
+ }
+ }
+
+ return FileCreatingResult.Create(currentStream, currentStream != stream);
+ }
+ catch
+ {
+ if (currentStream != stream)
+ {
+ await currentStream.DisposeAsync();
+ }
+
+ throw;
+ }
+ }
+
+ ///
+ /// Runs the post-create pipeline after the file was stored successfully.
+ ///
+ public async Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default)
+ {
+ if (!_handlers.Any())
+ {
+ return;
+ }
+
+ ArgumentNullException.ThrowIfNull(fileInfo);
+
+ foreach (var handler in _handlers)
+ {
+ await handler.CreatedAsync(fileInfo, cancellationToken);
+ }
+ }
+}
diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileEventHandler.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileEventHandler.cs
new file mode 100644
index 00000000000..78c4be8ed52
--- /dev/null
+++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileEventHandler.cs
@@ -0,0 +1,8 @@
+namespace OrchardCore.FileStorage;
+
+public interface IFileEventHandler
+{
+ Task CreatingAsync(FileCreatingContext context, Stream stream, CancellationToken cancellationToken = default);
+
+ Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default);
+}
diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj
index 5a8ad8eae76..90b9bbc7aa0 100644
--- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj
+++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/OrchardCore.FileStorage.Abstractions.csproj
@@ -15,4 +15,8 @@
+
+
+
+
diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs
index 837ab69caed..3cf44afc2eb 100644
--- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs
+++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Result.cs
@@ -7,22 +7,26 @@ namespace OrchardCore.Infrastructure;
///
public class Result
{
+ private static readonly ResultError[] _emptyErrors = [];
private static readonly Result _success = new Result { Succeeded = true };
- private readonly List _errors = [];
+ private IEnumerable _errors = _emptyErrors;
- private Result()
+ protected Result()
{
}
+ protected Result(bool succeeded)
+ {
+ Succeeded = succeeded;
+ }
+
///
/// Initializes a new instance of the class with the specified success status and errors.
///
- /// Indicates whether the operation succeeded.
/// The errors that occurred during the operation.
- protected Result(bool succeeded, List errors)
+ protected Result(IEnumerable errors)
{
- Succeeded = succeeded;
- _errors = errors;
+ _errors = errors ?? _emptyErrors;
}
///
@@ -48,7 +52,7 @@ protected Result(bool succeeded, List errors)
/// The value returned by the operation.
/// A successful result instance with the specified value.
public static Result Success(TValue value)
- => new Result(value, true, []);
+ => new Result(value);
///
/// Returns a failed result instance with the specified errors.
@@ -57,12 +61,7 @@ public static Result Success(TValue value)
/// A failed result instance with the specified errors.
public static Result Failed(params IEnumerable errors)
{
- var result = new Result();
-
- if (errors is not null)
- {
- result._errors.AddRange(errors);
- }
+ var result = new Result(errors);
return result;
}
@@ -91,5 +90,5 @@ public static Result Failed(LocalizedString error) => Failed(new ResultError
/// The errors that occurred during the operation.
/// A failed result instance with the specified error message.
public static Result Failed(params ResultError[] errors)
- => new Result(default, false, errors.ToList());
+ => new Result(default, errors);
}
diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs
index ef521ec0a38..19262b67bec 100644
--- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs
+++ b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/ResultOfT.cs
@@ -15,8 +15,16 @@ public class Result : Result
/// Initializes a new instance of the class.
///
/// The value returned by the operation.
- /// Indicates whether the operation succeeded.
/// The errors that occurred during the operation.
- protected internal Result(TValue value, bool succeeded, List errors)
- : base(succeeded, errors) => Value = value;
+ protected internal Result(TValue value, IEnumerable errors)
+ : base(errors)
+ {
+ Value = value;
+ }
+
+ protected internal Result(TValue value)
+ : base(true)
+ {
+ Value = value;
+ }
}
diff --git a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs
index 48b01b34ded..f607320b0be 100644
--- a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs
+++ b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs
@@ -17,6 +17,7 @@ public class DefaultMediaFileStore : IMediaFileStore
private readonly string _cdnBaseUrl;
private readonly IEnumerable _mediaEventHandlers;
private readonly IEnumerable _mediaCreatingEventHandlers;
+ private readonly FileCreationService _fileCreationService;
private readonly ILogger _logger;
private bool _requestBasePathValidated;
@@ -27,6 +28,7 @@ public DefaultMediaFileStore(
string cdnBaseUrl,
IEnumerable mediaEventHandlers,
IEnumerable mediaCreatingEventHandlers,
+ FileCreationService fileCreationService,
ILogger logger)
{
_fileStore = fileStore;
@@ -39,6 +41,7 @@ public DefaultMediaFileStore(
_mediaEventHandlers = mediaEventHandlers;
_mediaCreatingEventHandlers = mediaCreatingEventHandlers;
+ _fileCreationService = fileCreationService;
_logger = logger;
}
@@ -192,9 +195,7 @@ public virtual async Task CreateFileFromStreamAsync(string path, Stream
}
}
- await ValidateAvailableStorageAsync(outputStream.Length);
-
- return await _fileStore.CreateFileFromStreamAsync(context.Path, outputStream, overwrite);
+ return await CreateFileAsync(new FileCreatingContext(context.Path), outputStream, overwrite);
}
finally
{
@@ -204,9 +205,7 @@ public virtual async Task CreateFileFromStreamAsync(string path, Stream
}
else
{
- await ValidateAvailableStorageAsync(inputStream.Length);
-
- return await _fileStore.CreateFileFromStreamAsync(path, inputStream, overwrite);
+ return await CreateFileAsync(new FileCreatingContext(path), inputStream, overwrite);
}
}
@@ -262,4 +261,23 @@ private async Task ValidateAvailableStorageAsync(long requiredStorageSpace)
$"a file that fits the available space, or delete some unnecessary files.");
}
}
+
+ private async Task CreateFileAsync(FileCreatingContext fileCreatingContext, Stream stream, bool overwrite)
+ {
+ await using var fileCreatingResult = await _fileCreationService.CreateAsync(fileCreatingContext, stream);
+
+ if (!fileCreatingResult.Succeeded)
+ {
+ throw new FileStoreException(fileCreatingResult.ErrorMessage ?? $"The file '{fileCreatingContext.FileName}' was rejected.");
+ }
+
+ await ValidateAvailableStorageAsync(fileCreatingResult.Stream.Length);
+
+ var createdPath = await _fileStore.CreateFileFromStreamAsync(fileCreatingContext.Path, fileCreatingResult.Stream, overwrite);
+ var fileInfo = await _fileStore.GetFileInfoAsync(createdPath);
+
+ await _fileCreationService.CreatedAsync(fileInfo);
+
+ return createdPath;
+ }
}
diff --git a/src/docs/reference/README.md b/src/docs/reference/README.md
index bead5c3a123..bdfecaa43a0 100644
--- a/src/docs/reference/README.md
+++ b/src/docs/reference/README.md
@@ -70,6 +70,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance.
- Media:
- [Media](modules/Media/README.md)
- [Media Slugify](modules/Media.Slugify/README.md)
+ - [Antivirus](modules/Antivirus/README.md)
- [Media Amazon S3](modules/Media.AmazonS3/README.md)
- [Media Azure](modules/Media.Azure/README.md)
- [XML-RPC](modules/XmlRpc/README.md)
@@ -91,6 +92,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance.
### Extensibility
+- [File Upload Security](core/file-upload-security.md)
- [Auto Setup](modules/AutoSetup/README.md)
- [GraphQL](modules/Apis.GraphQL/README.md)
- [GraphQL queries](modules/Apis.GraphQL.Abstractions/README.md)
diff --git a/src/docs/reference/core/file-upload-security.md b/src/docs/reference/core/file-upload-security.md
new file mode 100644
index 00000000000..80a86f7acee
--- /dev/null
+++ b/src/docs/reference/core/file-upload-security.md
@@ -0,0 +1,111 @@
+# File Upload Security
+
+When your feature accepts a user-uploaded file and stores it outside Orchard Core's built-in media flow, use `FileCreationService` to run the shared pre-storage security pipeline.
+
+This ensures every `IFileEventHandler` can inspect or replace the stream before the file is written permanently. If any handler returns a failed `FileCreatingResult`, the upload must be aborted and the file must not be stored.
+
+## When to use `FileCreationService`
+
+Use `FileCreationService` in custom controllers, admin endpoints, APIs, recipe importers, or background flows that:
+
+- accept a file from a user;
+- write that file to disk, cloud storage, or another permanent store; and
+- do **not** already go through `IMediaFileStore.CreateFileFromStreamAsync()`.
+
+!!! note
+ `DefaultMediaFileStore` already uses `FileCreationService` internally. If your code uploads through `IMediaFileStore`, do not call the service a second time.
+
+## Core types
+
+- `FileCreationService`: runs `CreatingAsync()` before storage and `CreatedAsync()` after storage succeeds.
+- `FileCreatingContext`: describes the file being processed.
+- `FileCreatingResult`: returns the stream that should continue through the pipeline and whether creation should proceed.
+- `IFileEventHandler`: participates in the upload pipeline.
+
+## Ownership and cleanup
+
+- The original upload stream stays owned by the caller.
+- If a handler replaces the stream, `FileCreationService` disposes the superseded intermediate stream.
+- The `FileCreatingResult` returned from `CreateAsync()` owns the final replacement stream when one was created, so callers should use `await using` and keep that result alive for as long as they need the processed stream.
+- If `CreateAsync()` returns a failed result, dispose that result the same way and abort the upload without storing the file.
+- After the file has been written permanently, disposing `FileCreatingResult` cleans up any temporary replacement stream used during the upload pipeline.
+
+## Using `FileCreationService`
+
+Call `CreateAsync()` before writing the file. If the returned result did not succeed, abort the request. Only call `CreatedAsync()` after the file was stored successfully.
+
+```csharp
+using Microsoft.AspNetCore.Http;
+using OrchardCore.FileStorage;
+
+public sealed class CustomUploadService
+{
+ private readonly FileCreationService _fileCreationService;
+ private readonly IFileStore _fileStore;
+
+ public CustomUploadService(
+ FileCreationService fileCreationService,
+ IFileStore fileStore)
+ {
+ _fileCreationService = fileCreationService;
+ _fileStore = fileStore;
+ }
+
+ public async Task UploadAsync(IFormFile file, CancellationToken cancellationToken)
+ {
+ await using var uploadedStream = file.OpenReadStream();
+ await using var fileCreatingResult = await _fileCreationService.CreateAsync(
+ new FileCreatingContext(file.FileName, file.Length, file.ContentType),
+ uploadedStream,
+ cancellationToken);
+
+ if (!fileCreatingResult.Succeeded)
+ {
+ throw new FileStoreException(fileCreatingResult.ErrorMessage ?? $"The uploaded file '{file.FileName}' was rejected before it could be stored.");
+ }
+
+ // Use the processed stream while the result is still in scope.
+ var path = await _fileStore.CreateFileFromStreamAsync(file.FileName, fileCreatingResult.Stream);
+ var fileInfo = await _fileStore.GetFileInfoAsync(path);
+
+ await _fileCreationService.CreatedAsync(fileInfo, cancellationToken);
+
+ return path;
+ }
+}
+```
+
+## Implementing a handler
+
+Return `FileCreatingResult.Failed(...)` from `IFileEventHandler.CreatingAsync()` to stop the upload before it is stored. This is where a module would perform checks such as antivirus scanning, content inspection, or file-type validation.
+
+```csharp
+using Microsoft.Extensions.Localization;
+using OrchardCore.FileStorage;
+using OrchardCore.Infrastructure;
+
+namespace MyModule.Services;
+
+public sealed class RejectExecutableFileEventHandler : IFileEventHandler
+{
+ public Task CreatingAsync(FileCreatingContext context, Stream stream, CancellationToken cancellationToken = default)
+ {
+ if (string.Equals(Path.GetExtension(context.FileName), ".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.FromResult(FileCreatingResult.Failed(stream, new ResultError
+ {
+ Message = new LocalizedString(nameof(RejectExecutableFileEventHandler), "Executable files are not allowed."),
+ }));
+ }
+
+ return Task.FromResult(FileCreatingResult.Success(stream));
+ }
+
+ public Task CreatedAsync(IFileStoreEntry fileInfo, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+}
+```
+
+## Security guidance
+
+Always run `FileCreationService` before any permanent write. Do not save the uploaded file first and scan it afterward, since a failed scan must abort the upload before the file is persisted.
diff --git a/src/docs/reference/modules/Antivirus/README.md b/src/docs/reference/modules/Antivirus/README.md
new file mode 100644
index 00000000000..1a5056fffa1
--- /dev/null
+++ b/src/docs/reference/modules/Antivirus/README.md
@@ -0,0 +1,59 @@
+# Antivirus (`OrchardCore.Antivirus`)
+
+The Antivirus module provides features that inspect uploaded files before Orchard Core stores or imports them permanently.
+
+## ClamAV feature (`OrchardCore.Antivirus.ClamAV`)
+
+The ClamAV feature scans files with a `clamd` service before Orchard Core stores or imports them.
+
+When the feature is enabled, uploads fail closed:
+
+- Malware detections are rejected before storage.
+- Scanner connectivity, timeout, or protocol failures also reject the upload.
+- The ClamAV connection is reused per configuration to avoid creating a new TCP client for every scan.
+
+The scanner is wired through Orchard Core's file event handling abstractions, so uploads can be validated before storage without coupling media and deployment flows to a scanner-specific interface. ClamAV participates in that flow as an `IFileEventHandler`, and `FileCreationService` aborts the upload when ClamAV returns a failed `FileCreatingResult`.
+
+### Configuration
+
+Configure the ClamAV connection in application configuration. The settings key remains `OrchardCore_Antivirus_ClamAV` for compatibility:
+
+```json
+{
+ "OrchardCore": {
+ "OrchardCore_Antivirus_ClamAV": {
+ "Host": "localhost",
+ "Port": 3310,
+ "ConnectTimeoutSeconds": 5,
+ "TransferTimeoutSeconds": 30
+ }
+ }
+}
+```
+
+The same settings can be provided with environment variables:
+
+```text
+OrchardCore__Antivirus_ClamAV__Host=localhost
+OrchardCore__Antivirus_ClamAV__Port=3310
+OrchardCore__Antivirus_ClamAV__ConnectTimeoutSeconds=5
+OrchardCore__Antivirus_ClamAV__TransferTimeoutSeconds=30
+```
+
+### Usage
+
+1. Configure the ClamAV settings.
+2. Enable the `ClamAV Antivirus Scanner` feature (`OrchardCore.Antivirus.ClamAV`).
+3. Ensure a reachable `clamd` instance is running.
+
+If the feature is enabled without a valid ClamAV connection, uploads are rejected until the scanner can verify them.
+
+This feature integrates with the shared file upload security pipeline through `IFileEventHandler`, so uploads can be rejected before Orchard Core stores them permanently.
+
+See [File Upload Security](../../core/file-upload-security.md) for the canonical guidance on invoking `FileCreationService` in custom upload flows and aborting rejected files before they are stored.
+
+### Notes
+
+- The currently audited upload surfaces covered by this change are media uploads plus deployment package imports, both local and remote.
+- Media uploads are scanned before storage because they flow through `DefaultMediaFileStore`.
+- Deployment package zip/json uploads are scanned before Orchard Core writes the uploaded file to a temporary archive location for import.
diff --git a/src/docs/releases/3.0.0.md b/src/docs/releases/3.0.0.md
index df6e0c01f51..2cc968662d5 100644
--- a/src/docs/releases/3.0.0.md
+++ b/src/docs/releases/3.0.0.md
@@ -87,6 +87,19 @@ The email event handler callbacks on `IEmailServiceEvents`, including overrides
This is a source and binary breaking change for custom email services, providers, and event handlers. Update your implementations and overrides to include the new `cancellationToken` parameter.
+### File Upload Security
+
+Orchard Core now provides a file upload event pipeline for securing user-uploaded files before they are stored:
+
+- `IFileEventHandler`
+- `FileCreatingContext`
+- `FileCreatingResult`
+- `FileCreationService`
+
+Use `FileCreationService.CreateAsync()` anywhere your code accepts a user-uploaded file and stores it directly. If the returned `FileCreatingResult.Succeeded` value is `false`, you must abort the upload and avoid persisting the file. Only call `FileCreationService.CreatedAsync()` after the file was stored successfully.
+
+The ClamAV integration now uses this event pipeline through the `OrchardCore.Antivirus.ClamAV` feature.
+
### Admin Theme — Removal of `TheAdminThemeOptions` and CSS Helper Extensions
The `TheAdminThemeOptions` class and the `CssOrchardHelperExtensions` (from `OrchardCore.DisplayManagement`) have been removed. Admin editor views now use static CSS classes prefixed with `ocat-` (Orchard Core Admin Theme) instead of C# helper methods that injected CSS classes at runtime.
diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs
index c497dca55f1..b03d98f31e4 100644
--- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs
+++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/AssetUrlShortcodeTests.cs
@@ -35,6 +35,7 @@ public async Task ShouldProcess(string cdnBaseUrl, string text, string expected)
cdnBaseUrl,
[],
[],
+ new FileCreationService([]),
Mock.Of>());
var defaultHttpContext = new DefaultHttpContext();
diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/ClamAVFileEventHandlerTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ClamAVFileEventHandlerTests.cs
new file mode 100644
index 00000000000..26089194653
--- /dev/null
+++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ClamAVFileEventHandlerTests.cs
@@ -0,0 +1,301 @@
+using System.Buffers.Binary;
+using System.Net.Sockets;
+using OrchardCore.Antivirus.ClamAV;
+using OrchardCore.FileStorage;
+
+namespace OrchardCore.Tests.Modules.OrchardCore.Media;
+
+public class ClamAvFileEventHandlerTests
+{
+ [Fact]
+ public async Task CreatingAsync_ReturnsSeekableStream_WhenClamAvReturnsOk()
+ {
+ await using var server = await FakeClamAvServer.StartAsync("stream: OK\n");
+ using var factory = CreateFactory();
+ var handler = CreateHandler(server.Port, factory);
+
+ var inputStream = new MemoryStream("hello world"u8.ToArray());
+ var result = await handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), inputStream, TestContext.Current.CancellationToken);
+ var request = (await server.Completion).Single();
+
+ Assert.True(result.Succeeded);
+ Assert.Same(inputStream, result.Stream);
+ Assert.Equal("nINSTREAM\n", request.Command);
+ Assert.Equal("hello world"u8.ToArray(), request.Payload);
+ }
+
+ [Fact]
+ public async Task CreatingAsync_BuffersNonSeekableStreams()
+ {
+ await using var server = await FakeClamAvServer.StartAsync("stream: OK\n");
+ using var factory = CreateFactory();
+ var handler = CreateHandler(server.Port, factory);
+ await using var baseStream = new MemoryStream("hello world"u8.ToArray());
+ await using var inputStream = new NonSeekableReadStream(baseStream);
+
+ var result = await handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), inputStream);
+
+ Assert.True(result.Succeeded);
+ Assert.NotSame(inputStream, result.Stream);
+ Assert.True(result.Stream.CanSeek);
+
+ using var copy = new MemoryStream();
+ await result.Stream.CopyToAsync(copy);
+ Assert.Equal("hello world"u8.ToArray(), copy.ToArray());
+ }
+
+ [Fact]
+ public async Task CreatingAsync_ReturnsFailedResult_WhenClamAvFindsMalware()
+ {
+ await using var server = await FakeClamAvServer.StartAsync("stream: Eicar-Test-Signature FOUND\n");
+ using var factory = CreateFactory();
+ var handler = CreateHandler(server.Port, factory);
+
+ var result = await handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), new MemoryStream("hello world"u8.ToArray()));
+
+ Assert.False(result.Succeeded);
+ Assert.Equal("The uploaded file 'test.txt' was rejected because ClamAV detected 'Eicar-Test-Signature'.", result.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task CreatingAsync_Throws_WhenClamAvReturnsError()
+ {
+ await using var server = await FakeClamAvServer.StartAsync("INSTREAM size limit exceeded. ERROR\n");
+ using var factory = CreateFactory();
+ var handler = CreateHandler(server.Port, factory);
+
+ var exception = await Assert.ThrowsAsync(() =>
+ handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), new MemoryStream("hello world"u8.ToArray())));
+
+ Assert.Equal("The ClamAV antivirus scanner returned an unexpected response while scanning 'test.txt': INSTREAM size limit exceeded. ERROR", exception.Message);
+ }
+
+ [Fact]
+ public async Task CreatingAsync_Throws_WhenHostIsMissing()
+ {
+ using var factory = CreateFactory();
+ var handler = new ClamAvFileEventHandler(
+ Options.Create(new ClamAvOptions
+ {
+ Host = "",
+ }),
+ factory,
+ NullLogger.Instance);
+
+ var exception = await Assert.ThrowsAsync(() =>
+ handler.CreatingAsync(new FileCreatingContext("folder/test.txt"), new MemoryStream("hello world"u8.ToArray())));
+
+ Assert.Equal("The ClamAV antivirus scanner is enabled but the host setting is missing.", exception.Message);
+ }
+
+ [Fact]
+ public async Task CreatingAsync_ReusesTheSameTcpConnection()
+ {
+ await using var server = await FakeClamAvServer.StartAsync("stream: OK\n", "stream: OK\n");
+ using var factory = CreateFactory();
+ var handler = CreateHandler(server.Port, factory);
+
+ var firstResult = await handler.CreatingAsync(new FileCreatingContext("folder/first.txt"), new MemoryStream("first"u8.ToArray()));
+ var secondResult = await handler.CreatingAsync(new FileCreatingContext("folder/second.txt"), new MemoryStream("second"u8.ToArray()));
+ var requests = await server.Completion;
+
+ Assert.True(firstResult.Succeeded);
+ Assert.True(secondResult.Succeeded);
+ Assert.Equal(1, server.ConnectionCount);
+ Assert.Equal("first"u8.ToArray(), requests[0].Payload);
+ Assert.Equal("second"u8.ToArray(), requests[1].Payload);
+ }
+
+ private static ClamAvFileEventHandler CreateHandler(int port, ClamAvConnectionFactory factory)
+ => new(
+ Options.Create(new ClamAvOptions
+ {
+ Host = IPAddress.Loopback.ToString(),
+ Port = port,
+ ConnectTimeoutSeconds = 5,
+ TransferTimeoutSeconds = 5,
+ }),
+ factory,
+ NullLogger.Instance);
+
+ private static ClamAvConnectionFactory CreateFactory()
+ => new(Mock.Of(l => l.ApplicationStopped == CancellationToken.None), NullLoggerFactory.Instance);
+
+ private sealed class FakeClamAvServer : IAsyncDisposable
+ {
+ private readonly TcpListener _listener;
+
+ private FakeClamAvServer(TcpListener listener, Task completion, Func connectionCount)
+ {
+ _listener = listener;
+ Completion = completion;
+ _connectionCount = connectionCount;
+ }
+
+ private readonly Func _connectionCount;
+
+ public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
+
+ public int ConnectionCount => _connectionCount();
+
+ public Task Completion { get; }
+
+ public static Task StartAsync(params string[] responses)
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+
+ var connectionCount = 0;
+ var completion = Task.Run(async () =>
+ {
+ var requests = new List();
+
+ using var client = await listener.AcceptTcpClientAsync();
+ Interlocked.Increment(ref connectionCount);
+
+ await using var networkStream = client.GetStream();
+
+ foreach (var response in responses)
+ {
+ var command = await ReadLineAsync(networkStream);
+ var payload = await ReadPayloadAsync(networkStream);
+ var responseBytes = Encoding.ASCII.GetBytes(response);
+
+ requests.Add(new ClamAvRequest(command, payload));
+
+ await networkStream.WriteAsync(responseBytes);
+ await networkStream.FlushAsync();
+ }
+
+ return requests.ToArray();
+ });
+
+ return Task.FromResult(new FakeClamAvServer(listener, completion, () => connectionCount));
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _listener.Stop();
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static async Task ReadLineAsync(NetworkStream stream)
+ {
+ using var commandStream = new MemoryStream();
+ var buffer = new byte[1];
+
+ while (true)
+ {
+ var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 1));
+
+ if (bytesRead == 0)
+ {
+ break;
+ }
+
+ commandStream.WriteByte(buffer[0]);
+
+ if (buffer[0] == '\n')
+ {
+ break;
+ }
+ }
+
+ return Encoding.ASCII.GetString(commandStream.ToArray());
+ }
+
+ private static async Task ReadPayloadAsync(Stream stream)
+ {
+ using var payloadStream = new MemoryStream();
+ var lengthBuffer = new byte[sizeof(int)];
+
+ while (true)
+ {
+ await ReadExactlyAsync(stream, lengthBuffer);
+
+ var chunkLength = BinaryPrimitives.ReadInt32BigEndian(lengthBuffer);
+
+ if (chunkLength == 0)
+ {
+ break;
+ }
+
+ var chunk = new byte[chunkLength];
+ await ReadExactlyAsync(stream, chunk);
+ await payloadStream.WriteAsync(chunk);
+ }
+
+ return payloadStream.ToArray();
+ }
+
+ private static async Task ReadExactlyAsync(Stream stream, byte[] buffer)
+ {
+ var offset = 0;
+
+ while (offset < buffer.Length)
+ {
+ var bytesRead = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset));
+
+ if (bytesRead == 0)
+ {
+ throw new EndOfStreamException();
+ }
+
+ offset += bytesRead;
+ }
+ }
+ }
+
+ private sealed record ClamAvRequest(string Command, byte[] Payload);
+
+ private sealed class NonSeekableReadStream : Stream
+ {
+ private readonly Stream _innerStream;
+
+ public NonSeekableReadStream(Stream innerStream)
+ {
+ _innerStream = innerStream;
+ }
+
+ public override bool CanRead => _innerStream.CanRead;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush() => _innerStream.Flush();
+
+ public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _innerStream.DisposeAsync();
+ await base.DisposeAsync();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _innerStream.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs
index f1315b6e6e5..f992e7ac42e 100644
--- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs
+++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/DefaultMediaFileStoreTests.cs
@@ -1,4 +1,6 @@
+using Microsoft.Extensions.Localization;
using OrchardCore.FileStorage;
+using OrchardCore.Infrastructure;
using OrchardCore.Media.Core;
using OrchardCore.Media.Events;
@@ -21,6 +23,7 @@ public void MapPathToPublicUrl_ReturnsUrlEncodedPath(string path, string expecte
"",
[],
[],
+ CreateFileCreationService(),
Mock.Of>());
var result = store.MapPathToPublicUrl(path);
@@ -39,6 +42,7 @@ public void MapPathToPublicUrl_WithCdnBaseUrl_ReturnsUrlEncodedPath(string cdnBa
cdnBaseUrl,
[],
[],
+ CreateFileCreationService(),
Mock.Of>());
var result = store.MapPathToPublicUrl(path);
@@ -57,12 +61,17 @@ public async Task CreateFileFromStreamAsync_NoHandlers_CallsFileStoreDirectly()
.Setup(x => x.CreateFileFromStreamAsync("test.txt", inputStream, false))
.ReturnsAsync("result");
+ fileStoreMock
+ .Setup(x => x.GetFileInfoAsync("result"))
+ .ReturnsAsync(Mock.Of());
+
var store = new DefaultMediaFileStore(
fileStoreMock.Object,
"",
"",
new List(),
new List(),
+ CreateFileCreationService(),
loggerMock.Object);
// Act
@@ -95,6 +104,7 @@ public async Task CreateFileFromStreamAsync_HandlerReturnsInputStream_PassesInpu
"",
new List(),
new[] { handlerMock.Object },
+ CreateFileCreationService(),
loggerMock.Object);
// Act
@@ -129,12 +139,17 @@ public async Task CreateFileFromStreamAsync_HandlersCreateNewStreams_PassesCorre
.Setup(x => x.CreateFileFromStreamAsync("test.txt", stream2, false))
.ReturnsAsync("result");
+ fileStoreMock
+ .Setup(x => x.GetFileInfoAsync("result"))
+ .ReturnsAsync(Mock.Of());
+
var store = new DefaultMediaFileStore(
fileStoreMock.Object,
"",
"",
new List(),
new[] { handler1.Object, handler2.Object },
+ CreateFileCreationService(),
loggerMock.Object);
// Act
@@ -175,6 +190,7 @@ public async Task CreateFileFromStreamAsync_ValidateAvailableStorageAsync()
"",
[handler.Object],
[],
+ CreateFileCreationService(),
loggerMock.Object);
// Act
@@ -188,4 +204,112 @@ public async Task CreateFileFromStreamAsync_ValidateAvailableStorageAsync()
"uploading a file that fits the available space, or delete some unnecessary files.";
Assert.Equal(expectedMessage, exception.Message);
}
+
+ [Fact]
+ public async Task CreateFileFromStreamAsync_FileEventHandlerCanReplaceStreamBeforeSaving()
+ {
+ var fileStoreMock = new Mock();
+ var loggerMock = new Mock>();
+ var inputStream = new MemoryStream("ignored"u8.ToArray());
+ var replacementStream = new MemoryStream("hello world"u8.ToArray());
+ var handlerMock = new Mock();
+
+ handlerMock
+ .Setup(x => x.CreatingAsync(It.IsAny(), inputStream, It.IsAny()))
+ .ReturnsAsync(FileCreatingResult.Success(replacementStream));
+
+ fileStoreMock
+ .Setup(x => x.CreateFileFromStreamAsync("test.txt", It.IsAny(), false))
+ .ReturnsAsync("result")
+ .Callback((_, stream, _) =>
+ {
+ using var copy = new MemoryStream();
+ stream.CopyTo(copy);
+ Assert.Equal("hello world"u8.ToArray(), copy.ToArray());
+ });
+
+ fileStoreMock
+ .Setup(x => x.GetFileInfoAsync("result"))
+ .ReturnsAsync(Mock.Of());
+
+ var store = new DefaultMediaFileStore(
+ fileStoreMock.Object,
+ "",
+ "",
+ [],
+ [],
+ CreateFileCreationService(handlerMock.Object),
+ loggerMock.Object);
+
+ await store.CreateFileFromStreamAsync("test.txt", inputStream);
+ }
+
+ [Fact]
+ public async Task CreateFileFromStreamAsync_FileEventHandlerCanRejectFile()
+ {
+ var fileStoreMock = new Mock(MockBehavior.Strict);
+ var loggerMock = new Mock>();
+ var inputStream = new MemoryStream("hello world"u8.ToArray());
+ var handlerMock = new Mock();
+
+ handlerMock
+ .Setup(x => x.CreatingAsync(It.IsAny(), inputStream, It.IsAny()))
+ .ReturnsAsync(FileCreatingResult.Failed(stream: null,
+ new ResultError
+ {
+ Message = new LocalizedString("Rejected", "The uploaded file was rejected by the anti-virus scanner."),
+ }));
+
+ var store = new DefaultMediaFileStore(
+ fileStoreMock.Object,
+ "",
+ "",
+ [],
+ [],
+ CreateFileCreationService(handlerMock.Object),
+ loggerMock.Object);
+
+ var exception = await Assert.ThrowsAsync(() =>
+ store.CreateFileFromStreamAsync("test.txt", inputStream));
+
+ Assert.Equal("The uploaded file was rejected by the anti-virus scanner.", exception.Message);
+ }
+
+ [Fact]
+ public async Task CreateFileFromStreamAsync_FileEventHandlersRunCreatedAfterTheFileIsStored()
+ {
+ var fileStoreMock = new Mock();
+ var loggerMock = new Mock>();
+ var inputStream = new MemoryStream("hello world"u8.ToArray());
+ var fileInfoMock = new Mock();
+ var handlerMock = new Mock();
+
+ handlerMock
+ .Setup(x => x.CreatingAsync(It.IsAny(), inputStream, It.IsAny()))
+ .ReturnsAsync(FileCreatingResult.Success(inputStream));
+
+ fileStoreMock
+ .Setup(x => x.CreateFileFromStreamAsync("test.txt", inputStream, false))
+ .ReturnsAsync("result");
+
+ fileStoreMock
+ .Setup(x => x.GetFileInfoAsync("result"))
+ .ReturnsAsync(fileInfoMock.Object);
+
+ var store = new DefaultMediaFileStore(
+ fileStoreMock.Object,
+ "",
+ "",
+ [],
+ [],
+ CreateFileCreationService(handlerMock.Object),
+ loggerMock.Object);
+
+ await store.CreateFileFromStreamAsync("test.txt", inputStream);
+
+ handlerMock.Verify(x => x.CreatedAsync(fileInfoMock.Object, It.IsAny()), Times.Once);
+ }
+
+ private static FileCreationService CreateFileCreationService(params IFileEventHandler[] handlers)
+ => new(handlers);
}
diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/FileCreationServiceTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/FileCreationServiceTests.cs
new file mode 100644
index 00000000000..5502af85310
--- /dev/null
+++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/FileCreationServiceTests.cs
@@ -0,0 +1,81 @@
+using OrchardCore.FileStorage;
+using OrchardCore.Infrastructure;
+
+namespace OrchardCore.Tests.Modules.OrchardCore.Media;
+
+public class FileCreationServiceTests
+{
+ [Fact]
+ public async Task CreateAsync_DisposesReplacementStream_WhenLaterHandlerThrows()
+ {
+ var originalStream = new TrackingStream();
+ var replacementStream = new TrackingStream();
+
+ var firstHandler = new Mock();
+ firstHandler
+ .Setup(x => x.CreatingAsync(It.IsAny(), originalStream, It.IsAny()))
+ .ReturnsAsync(FileCreatingResult.Success(replacementStream));
+
+ var secondHandler = new Mock();
+ secondHandler
+ .Setup(x => x.CreatingAsync(It.IsAny(), replacementStream, It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Handler failure."));
+
+ var service = new FileCreationService([firstHandler.Object, secondHandler.Object]);
+
+ await Assert.ThrowsAsync(() =>
+ service.CreateAsync(new FileCreatingContext("file.txt"), originalStream));
+
+ Assert.True(replacementStream.IsDisposed);
+ Assert.False(originalStream.IsDisposed);
+ }
+
+ [Fact]
+ public async Task CreateAsync_ReturnedFailedResult_DisposesReplacementStream()
+ {
+ var originalStream = new TrackingStream();
+ var replacementStream = new TrackingStream();
+
+ var firstHandler = new Mock();
+ firstHandler
+ .Setup(x => x.CreatingAsync(It.IsAny(), originalStream, It.IsAny()))
+ .ReturnsAsync(FileCreatingResult.Success(replacementStream));
+
+ var secondHandler = new Mock();
+ secondHandler
+ .Setup(x => x.CreatingAsync(It.IsAny(), replacementStream, It.IsAny()))
+ .ReturnsAsync(FileCreatingResult.Failed(replacementStream, new ResultError
+ {
+ Message = new LocalizedString("Rejected", "The file was rejected."),
+ }));
+
+ var service = new FileCreationService([firstHandler.Object, secondHandler.Object]);
+
+ await using (var result = await service.CreateAsync(new FileCreatingContext("file.txt"), originalStream, TestContext.Current.CancellationToken))
+ {
+ Assert.False(result.Succeeded);
+ Assert.Same(replacementStream, result.Stream);
+ Assert.False(replacementStream.IsDisposed);
+ }
+
+ Assert.True(replacementStream.IsDisposed);
+ Assert.False(originalStream.IsDisposed);
+ }
+
+ private sealed class TrackingStream : MemoryStream
+ {
+ public bool IsDisposed { get; private set; }
+
+ protected override void Dispose(bool disposing)
+ {
+ IsDisposed = true;
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ IsDisposed = true;
+ await base.DisposeAsync();
+ }
+ }
+}
diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs
index 46817307e8e..09d7488a639 100644
--- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs
+++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/ImageShortcodeTests.cs
@@ -46,6 +46,7 @@ public async Task ShouldProcess(string cdnBaseUrl, string text, string expected)
cdnBaseUrl,
[],
[],
+ new FileCreationService([]),
Mock.Of>());
var fileVersionProvider = Mock.Of();
diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs
index 030eff3781a..7c774cfce9f 100644
--- a/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs
+++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media/MediaOrchardHelperExtensionsTests.cs
@@ -43,6 +43,7 @@ private static TestOrchardHelper CreateOrchardHelper()
"",
[],
[],
+ new FileCreationService([]),
Mock.Of>());
var mediaProfileServiceMock = new Mock();
diff --git a/test/OrchardCore.Tests/OrchardCore.Tests.csproj b/test/OrchardCore.Tests/OrchardCore.Tests.csproj
index 125dac70836..d1dc44fcfc2 100644
--- a/test/OrchardCore.Tests/OrchardCore.Tests.csproj
+++ b/test/OrchardCore.Tests/OrchardCore.Tests.csproj
@@ -52,9 +52,11 @@
+
+