* Harden initial security remediation slice * Address security review feedback * Handle null resume request bodies * Address security review follow-ups * Address PR review security comments * Address review comments for zip cache and resume parsing Agent-Logs-Url: https://github.com/elsa-workflows/elsa-core/sessions/23734885-ebba-4a37-b888-1ded4cc7cff5 Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Tighten zip cache path validation Agent-Logs-Url: https://github.com/elsa-workflows/elsa-core/sessions/23734885-ebba-4a37-b888-1ded4cc7cff5 Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Address ZipManager code quality review * Fix ZipManager test options reference * Harden cached zip path validation * Address PR review hardening feedback * Allow dotted zip download correlation ids --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
285 lines
10 KiB
C#
285 lines
10 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.IO.Compression;
|
|
using System.Reflection;
|
|
using Elsa.Common;
|
|
using Elsa.Http;
|
|
using Elsa.Http.FileCaches;
|
|
using Elsa.Http.Options;
|
|
using FluentStorage;
|
|
using FluentStorage.Blobs;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.Activities.UnitTests.Http;
|
|
|
|
public class ZipManagerTests : IDisposable
|
|
{
|
|
private static readonly Type ZipManagerType = typeof(WriteFileHttpResponse).Assembly.GetRequiredType("Elsa.Http.Services.ZipManager");
|
|
private static readonly MethodInfo CreateAsyncMethod = ZipManagerType.GetRequiredMethod("CreateAsync");
|
|
private static readonly MethodInfo LoadAsyncMethod = ZipManagerType.GetRequiredMethod("LoadAsync");
|
|
private readonly string _cacheDirectory = Path.Join(Path.GetTempPath(), "elsa-zip-manager-tests", Guid.NewGuid().ToString("N"));
|
|
private readonly IBlobStorage _blobStorage;
|
|
private readonly object _zipManager;
|
|
private readonly DateTimeOffset _now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
public ZipManagerTests()
|
|
{
|
|
Directory.CreateDirectory(_cacheDirectory);
|
|
_blobStorage = StorageFactory.Blobs.DirectoryFiles(_cacheDirectory);
|
|
_zipManager = CreateZipManager();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_ValidToken_LoadsCachedZip()
|
|
{
|
|
var downloadId = "download_ABC-123";
|
|
await CreateCachedZipAsync(downloadId, "cached zip");
|
|
|
|
var result = await LoadAsync(downloadId);
|
|
|
|
Assert.True(result.HasValue);
|
|
var value = result.Value;
|
|
using var zipStream = new MemoryStream(value.Content);
|
|
using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Read);
|
|
var entry = Assert.Single(zipArchive.Entries);
|
|
await using var entryStream = entry.Open();
|
|
using var reader = new StreamReader(entryStream);
|
|
Assert.Equal("cached zip", await reader.ReadToEndAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_ValidTokenWithDots_LoadsCachedZip()
|
|
{
|
|
var downloadId = "Elsa.Alterations.ExecuteAlterationPlan";
|
|
await CreateCachedZipAsync(downloadId, "cached zip");
|
|
|
|
var result = await LoadAsync(downloadId);
|
|
|
|
Assert.True(result.HasValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_ValidToken_OpensReturnedBlobFullPathWithinCacheDirectory()
|
|
{
|
|
var provider = Substitute.For<IFileCacheStorageProvider>();
|
|
var blobPath = Path.Join(_cacheDirectory, "download.tmp");
|
|
var blobStorage = new BlobStorageStub("download.tmp", blobPath, [1, 2, 3], _now.AddMinutes(5));
|
|
var zipManager = CreateZipManager(provider);
|
|
provider.GetStorage().Returns(blobStorage);
|
|
|
|
var result = await LoadAsync("download", zipManager);
|
|
|
|
Assert.True(result.HasValue);
|
|
Assert.Equal([1, 2, 3], result.Value.Content);
|
|
Assert.Equal(blobPath, blobStorage.OpenedPath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_RootedPathOutsideCacheDirectory_ReturnsNull()
|
|
{
|
|
var provider = Substitute.For<IFileCacheStorageProvider>();
|
|
var blobStorage = new BlobStorageStub("download.tmp", Path.Join(Path.GetTempPath(), "download.tmp"), [1, 2, 3], _now.AddMinutes(5));
|
|
var zipManager = CreateZipManager(provider);
|
|
provider.GetStorage().Returns(blobStorage);
|
|
|
|
var result = await LoadAsync("download", zipManager);
|
|
|
|
Assert.Null(result);
|
|
Assert.Null(blobStorage.OpenedPath);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("../download")]
|
|
[InlineData("..\\download")]
|
|
[InlineData("nested/download")]
|
|
[InlineData("nested\\download")]
|
|
[InlineData("download token")]
|
|
public async Task LoadAsync_TraversalLikeToken_ReturnsNull(string downloadId)
|
|
{
|
|
var result = await LoadAsync(downloadId);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_TooLongToken_ReturnsNull()
|
|
{
|
|
var downloadId = new string('a', 129);
|
|
|
|
var result = await LoadAsync(downloadId);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("../download")]
|
|
[InlineData("..\\download")]
|
|
public async Task CreateAsync_TraversalLikeToken_DoesNotCacheFile(string downloadId)
|
|
{
|
|
await CreateCachedZipAsync(downloadId, "cached zip");
|
|
|
|
Assert.Empty(Directory.EnumerateFileSystemEntries(_cacheDirectory));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_TooLongToken_DoesNotCacheFile()
|
|
{
|
|
var downloadId = new string('a', 129);
|
|
|
|
await CreateCachedZipAsync(downloadId, "cached zip");
|
|
|
|
Assert.Empty(Directory.EnumerateFileSystemEntries(_cacheDirectory));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_cacheDirectory))
|
|
Directory.Delete(_cacheDirectory, true);
|
|
}
|
|
|
|
[UnconditionalSuppressMessage("Trimming", "IL2077:Target type is resolved by name for an internal test subject.")]
|
|
private object CreateZipManager(IFileCacheStorageProvider? fileCacheStorageProvider = null)
|
|
{
|
|
var clock = Substitute.For<ISystemClock>();
|
|
clock.UtcNow.Returns(_now);
|
|
fileCacheStorageProvider ??= new BlobFileCacheStorageProvider(_blobStorage);
|
|
var options = Microsoft.Extensions.Options.Options.Create(new HttpFileCacheOptions
|
|
{
|
|
LocalCacheDirectory = _cacheDirectory,
|
|
TimeToLive = TimeSpan.FromHours(1)
|
|
});
|
|
var logger = typeof(NullLogger<>).MakeGenericType(ZipManagerType).GetRequiredField("Instance").GetValue(null)!;
|
|
|
|
return Activator.CreateInstance(ZipManagerType, clock, fileCacheStorageProvider, options, logger)!;
|
|
}
|
|
|
|
private async Task CreateCachedZipAsync(string downloadId, string content)
|
|
{
|
|
var downloadables = new List<Func<ValueTask<Downloadable>>>
|
|
{
|
|
() => ValueTask.FromResult(new Downloadable
|
|
{
|
|
Stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)),
|
|
Filename = "file.txt"
|
|
})
|
|
};
|
|
var task = (Task)CreateAsyncMethod.Invoke(_zipManager, [downloadables, true, downloadId, "download.zip", "application/zip", CancellationToken.None])!;
|
|
await task;
|
|
var result = task.GetType().GetRequiredProperty("Result").GetValue(task)!;
|
|
var zipStream = (Stream)result.GetType().GetRequiredField("Item2").GetValue(result)!;
|
|
var cleanup = (Action)result.GetType().GetRequiredField("Item3").GetValue(result)!;
|
|
|
|
try
|
|
{
|
|
await zipStream.DisposeAsync();
|
|
}
|
|
finally
|
|
{
|
|
cleanup();
|
|
}
|
|
}
|
|
|
|
private async Task<(Blob Blob, byte[] Content)?> LoadAsync(string downloadId, object? zipManager = null)
|
|
{
|
|
var task = (Task)LoadAsyncMethod.Invoke(zipManager ?? _zipManager, [downloadId, CancellationToken.None])!;
|
|
await task;
|
|
var result = task.GetType().GetRequiredProperty("Result").GetValue(task);
|
|
|
|
if (result == null)
|
|
return null;
|
|
|
|
var blob = (Blob)result.GetType().GetRequiredField("Item1").GetValue(result)!;
|
|
await using var stream = (Stream)result.GetType().GetRequiredField("Item2").GetValue(result)!;
|
|
using var memoryStream = new MemoryStream();
|
|
await stream.CopyToAsync(memoryStream);
|
|
return (blob, memoryStream.ToArray());
|
|
}
|
|
}
|
|
|
|
internal static class ReflectionExtensions
|
|
{
|
|
public static Type GetRequiredType(this Assembly assembly, string typeName)
|
|
{
|
|
return assembly.GetType(typeName) ?? throw new InvalidOperationException($"Could not find type {typeName}.");
|
|
}
|
|
|
|
public static MethodInfo GetRequiredMethod(this Type type, string methodName)
|
|
{
|
|
return type.GetMethod(methodName) ?? throw new InvalidOperationException($"Could not find method {type.FullName}.{methodName}.");
|
|
}
|
|
|
|
public static PropertyInfo GetRequiredProperty(this Type type, string propertyName)
|
|
{
|
|
return type.GetProperty(propertyName) ?? throw new InvalidOperationException($"Could not find property {type.FullName}.{propertyName}.");
|
|
}
|
|
|
|
public static FieldInfo GetRequiredField(this Type type, string fieldName)
|
|
{
|
|
return type.GetField(fieldName) ?? throw new InvalidOperationException($"Could not find field {type.FullName}.{fieldName}.");
|
|
}
|
|
}
|
|
|
|
internal sealed class BlobStorageStub(string lookupPath, string fullPath, byte[] content, DateTimeOffset expiresAt) : IBlobStorage
|
|
{
|
|
public string? OpenedPath { get; private set; }
|
|
|
|
public Task<IReadOnlyCollection<Blob>> ListAsync(ListOptions? options = null, CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task WriteAsync(string fullPath, Stream dataStream, bool append = false, CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<Stream> OpenReadAsync(string fullPath, CancellationToken cancellationToken = default)
|
|
{
|
|
OpenedPath = fullPath;
|
|
return Task.FromResult(CreateContentStream());
|
|
}
|
|
|
|
public Task DeleteAsync(IEnumerable<string> fullPaths, CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<IReadOnlyCollection<bool>> ExistsAsync(IEnumerable<string> fullPaths, CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<IReadOnlyCollection<Blob>> GetBlobsAsync(IEnumerable<string> fullPaths, CancellationToken cancellationToken = default)
|
|
{
|
|
var blobs = fullPaths.Contains(lookupPath)
|
|
? [CreateBlob()]
|
|
: Array.Empty<Blob>();
|
|
|
|
return Task.FromResult<IReadOnlyCollection<Blob>>(blobs);
|
|
}
|
|
|
|
public Task SetBlobsAsync(IEnumerable<Blob> blobs, CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<ITransaction> OpenTransactionAsync()
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private Blob CreateBlob()
|
|
{
|
|
var blob = new Blob(fullPath);
|
|
blob.Metadata["ExpiresAt"] = expiresAt.ToString("O");
|
|
return blob;
|
|
}
|
|
|
|
private Stream CreateContentStream() => new MemoryStream(content, writable: false);
|
|
}
|