From 37de3404cd587905397809c141fb3e7a600c77bd Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 20 May 2026 12:56:18 +0200 Subject: [PATCH] Whitelist workflow timestamp filter columns --- .../Endpoints/Alterations/DryRun/Endpoint.cs | 21 ++- .../Endpoints/Alterations/Submit/Endpoint.cs | 21 ++- .../WorkflowInstances/List/Endpoint.cs | 25 +--- .../Filters/WorkflowInstanceFilter.cs | 63 ++++++++- .../AlterationsApiTimestampFilterTests.cs | 100 +++++++++++++++ ...kflowInstanceFinderTimestampFilterTests.cs | 102 +++++++++++++++ .../WorkflowInstanceFilterTimestampTests.cs | 121 ++++++++++++++++++ 7 files changed, 428 insertions(+), 25 deletions(-) create mode 100644 test/integration/Elsa.Alterations.IntegrationTests/AlterationsApiTimestampFilterTests.cs create mode 100644 test/integration/Elsa.Alterations.IntegrationTests/WorkflowInstanceFinderTimestampFilterTests.cs create mode 100644 test/unit/Elsa.Workflows.Management.UnitTests/Filters/WorkflowInstanceFilterTimestampTests.cs diff --git a/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs b/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs index 4bc173d30..1445600f2 100644 --- a/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs +++ b/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs @@ -1,7 +1,9 @@ using Elsa.Abstractions; using Elsa.Alterations.Core.Contracts; using Elsa.Alterations.Core.Models; +using Elsa.Workflows.Management.Filters; using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; namespace Elsa.Alterations.Endpoints.Alterations.DryRun; @@ -21,8 +23,25 @@ public class DryRun(IWorkflowInstanceFinder workflowInstanceFinder) : ElsaEndpoi /// public override async Task HandleAsync(AlterationWorkflowInstanceFilter filter, CancellationToken cancellationToken) { + if (!ValidateInput(filter)) + { + await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return; + } + var workflowInstanceIds = await workflowInstanceFinder.FindAsync(filter, cancellationToken); var response = new Response(workflowInstanceIds.ToList()); await Send.OkAsync(response, cancellationToken); } -} \ No newline at end of file + + private bool ValidateInput(AlterationWorkflowInstanceFilter filter) + { + foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(filter.TimestampFilters)) + { + AddError(error); + return false; + } + + return true; + } +} diff --git a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs index e36a910b5..4d7e57d5b 100644 --- a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs +++ b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs @@ -3,7 +3,9 @@ using Elsa.Alterations.Core.Contracts; using Elsa.Alterations.Core.Models; using Elsa.Common; using Elsa.Workflows; +using Elsa.Workflows.Management.Filters; using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; namespace Elsa.Alterations.Endpoints.Alterations.Submit; @@ -35,6 +37,12 @@ public class Submit : ElsaEndpoint /// public override async Task HandleAsync(AlterationPlanParams planParams, CancellationToken cancellationToken) { + if (!ValidateInput(planParams)) + { + await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken); + return; + } + // Submit the plan. var planId = await _alterationPlanScheduler.SubmitAsync(planParams, cancellationToken); @@ -42,4 +50,15 @@ public class Submit : ElsaEndpoint var response = new Response(planId); await Send.OkAsync(response, cancellationToken); } -} \ No newline at end of file + + private bool ValidateInput(AlterationPlanParams planParams) + { + foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(planParams.Filter.TimestampFilters)) + { + AddError(error); + return false; + } + + return true; + } +} diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs index c8ad5401b..2ae8ee07c 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs @@ -84,27 +84,10 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint public class WorkflowInstanceFilter { + private static readonly string[] TimestampFilterColumns = + [ + nameof(WorkflowInstance.CreatedAt), + nameof(WorkflowInstance.UpdatedAt), + nameof(WorkflowInstance.FinishedAt) + ]; + + /// + /// The workflow instance timestamp columns that can be used by . + /// + public static IReadOnlyCollection AllowedTimestampFilterColumns { get; } = Array.AsReadOnly(TimestampFilterColumns); + /// /// Filter workflow instances by ID. /// @@ -154,7 +166,7 @@ public class WorkflowInstanceFilter { foreach (var timestampFilter in TimestampFilters) { - var column = timestampFilter.Column; + var column = NormalizeTimestampFilterColumn(timestampFilter.Column); var timestamp = timestampFilter.Timestamp; var isZeroTime = timestamp.TimeOfDay == TimeSpan.Zero; var startDay = new DateTimeOffset(timestamp.Date); @@ -194,4 +206,51 @@ public class WorkflowInstanceFilter return query; } -} \ No newline at end of file + + /// + /// Validates timestamp filters. + /// + public static IEnumerable ValidateTimestampFilters(IEnumerable? timestampFilters) + { + if (timestampFilters == null) + yield break; + + foreach (var timestampFilter in timestampFilters) + { + if (!TryNormalizeTimestampFilterColumn(timestampFilter.Column, out _, out var error)) + yield return error; + } + } + + /// + /// Resolves a timestamp filter column to its canonical workflow instance property name. + /// + public static bool TryNormalizeTimestampFilterColumn(string? column, [NotNullWhen(true)] out string? normalizedColumn, [NotNullWhen(false)] out string? error) + { + normalizedColumn = null; + error = null; + + if (string.IsNullOrWhiteSpace(column)) + { + error = "Timestamp filter column must be specified."; + return false; + } + + var trimmedColumn = column.Trim(); + normalizedColumn = TimestampFilterColumns.FirstOrDefault(x => string.Equals(x, trimmedColumn, StringComparison.OrdinalIgnoreCase)); + + if (normalizedColumn != null) + return true; + + error = $"Invalid timestamp filter column '{column}'. Allowed columns are: {string.Join(", ", TimestampFilterColumns)}."; + return false; + } + + private static string NormalizeTimestampFilterColumn(string? column) + { + if (TryNormalizeTimestampFilterColumn(column, out var normalizedColumn, out var error)) + return normalizedColumn; + + throw new ArgumentException(error, nameof(TimestampFilters)); + } +} diff --git a/test/integration/Elsa.Alterations.IntegrationTests/AlterationsApiTimestampFilterTests.cs b/test/integration/Elsa.Alterations.IntegrationTests/AlterationsApiTimestampFilterTests.cs new file mode 100644 index 000000000..196dc7939 --- /dev/null +++ b/test/integration/Elsa.Alterations.IntegrationTests/AlterationsApiTimestampFilterTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Http.Json; +using Elsa.Alterations.Core.Contracts; +using Elsa.Alterations.Endpoints.Alterations.DryRun; +using Elsa.Common; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Enums; +using Elsa.Workflows.Runtime; +using FastEndpoints; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using SubmitEndpoint = Elsa.Alterations.Endpoints.Alterations.Submit.Submit; + +namespace Elsa.Alterations.IntegrationTests; + +public class AlterationsApiTimestampFilterTests : IAsyncLifetime +{ + private WebApplication? _app; + private bool _wasSecurityEnabled; + private HttpClient _httpClient = null!; + + public async Task InitializeAsync() + { + _wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled; + EndpointSecurityOptions.SecurityIsEnabled = false; + + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddFastEndpoints(o => + { + o.Assemblies = [typeof(DryRun).Assembly]; + o.Filter = endpointType => endpointType == typeof(DryRun) || endpointType == typeof(SubmitEndpoint); + }); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddLogging(); + + _app = builder.Build(); + _app.UseFastEndpoints(); + + await _app.StartAsync(); + _httpClient = _app.GetTestClient(); + } + + public async Task DisposeAsync() + { + EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled; + _httpClient.Dispose(); + + if (_app == null) + return; + + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + [Theory] + [InlineData("/alterations/dry-run")] + [InlineData("/alterations/submit")] + public async Task Post_WithInjectedTimestampFilterColumn_ReturnsBadRequest(string path) + { + var response = await _httpClient.PostAsJsonAsync(path, CreateRequest(path)); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Invalid timestamp filter column", body); + } + + private static object CreateRequest(string path) + { + var filter = new + { + timestampFilters = new[] + { + new + { + column = "CreatedAt == @0 || Id != null", + @operator = TimestampFilterOperator.Is, + timestamp = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero) + } + } + }; + + return path == "/alterations/submit" + ? new { filter } + : filter; + } +} diff --git a/test/integration/Elsa.Alterations.IntegrationTests/WorkflowInstanceFinderTimestampFilterTests.cs b/test/integration/Elsa.Alterations.IntegrationTests/WorkflowInstanceFinderTimestampFilterTests.cs new file mode 100644 index 000000000..85136a420 --- /dev/null +++ b/test/integration/Elsa.Alterations.IntegrationTests/WorkflowInstanceFinderTimestampFilterTests.cs @@ -0,0 +1,102 @@ +using Elsa.Alterations.Core.Contracts; +using Elsa.Alterations.Core.Models; +using Elsa.Alterations.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Enums; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.State; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.Alterations.IntegrationTests; + +public class WorkflowInstanceFinderTimestampFilterTests +{ + private readonly IWorkflowInstanceFinder _workflowInstanceFinder; + private readonly IWorkflowInstanceStore _workflowInstanceStore; + + public WorkflowInstanceFinderTimestampFilterTests(ITestOutputHelper testOutputHelper) + { + var services = new TestApplicationBuilder(testOutputHelper) + .ConfigureElsa(elsa => elsa.UseAlterations()) + .Build(); + + _workflowInstanceFinder = services.GetRequiredService(); + _workflowInstanceStore = services.GetRequiredService(); + } + + [Fact] + public async Task FindAsync_WithAllowedTimestampColumn_FiltersWorkflowInstances() + { + var timestamp = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero); + await _workflowInstanceStore.AddAsync(CreateWorkflowInstance("matching", timestamp)); + await _workflowInstanceStore.AddAsync(CreateWorkflowInstance("older", timestamp.AddDays(-1))); + + var result = await _workflowInstanceFinder.FindAsync(new() + { + TimestampFilters = + [ + new() + { + Column = nameof(WorkflowInstance.CreatedAt), + Operator = TimestampFilterOperator.GreaterThanOrEqual, + Timestamp = timestamp + } + ] + }); + + var workflowInstanceId = Assert.Single(result); + Assert.Equal("matching", workflowInstanceId); + } + + [Fact] + public async Task FindAsync_WithInjectedTimestampColumn_RejectsColumn() + { + var timestamp = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero); + await _workflowInstanceStore.AddAsync(CreateWorkflowInstance("matching", timestamp)); + + var exception = await Assert.ThrowsAsync(() => _workflowInstanceFinder.FindAsync(new() + { + TimestampFilters = + [ + new() + { + Column = "CreatedAt == @0 || Id != null", + Operator = TimestampFilterOperator.Is, + Timestamp = timestamp + } + ] + })); + + Assert.Contains("Invalid timestamp filter column", exception.Message); + } + + private static WorkflowInstance CreateWorkflowInstance(string id, DateTimeOffset createdAt) + { + return new() + { + Id = id, + DefinitionId = "definition", + DefinitionVersionId = "definition-version", + Version = 1, + WorkflowState = new WorkflowState + { + Id = id, + DefinitionId = "definition", + DefinitionVersionId = "definition-version", + DefinitionVersion = 1, + Status = WorkflowStatus.Running, + SubStatus = WorkflowSubStatus.Suspended, + CreatedAt = createdAt, + UpdatedAt = createdAt + }, + Status = WorkflowStatus.Running, + SubStatus = WorkflowSubStatus.Suspended, + CreatedAt = createdAt, + UpdatedAt = createdAt + }; + } +} diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Filters/WorkflowInstanceFilterTimestampTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Filters/WorkflowInstanceFilterTimestampTests.cs new file mode 100644 index 000000000..9854212e3 --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Filters/WorkflowInstanceFilterTimestampTests.cs @@ -0,0 +1,121 @@ +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Enums; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.State; + +namespace Elsa.Workflows.Management.UnitTests.Filters; + +public class WorkflowInstanceFilterTimestampTests +{ + private readonly DateTimeOffset _matchingCreatedAt = new(2026, 5, 20, 10, 0, 0, TimeSpan.Zero); + private readonly DateTimeOffset _matchingUpdatedAt = new(2026, 5, 20, 11, 0, 0, TimeSpan.Zero); + private readonly DateTimeOffset _matchingFinishedAt = new(2026, 5, 20, 12, 0, 0, TimeSpan.Zero); + private readonly IQueryable _workflowInstances; + + public WorkflowInstanceFilterTimestampTests() + { + _workflowInstances = new[] + { + CreateWorkflowInstance("matching", _matchingCreatedAt, _matchingUpdatedAt, _matchingFinishedAt), + CreateWorkflowInstance("older", _matchingCreatedAt.AddDays(-1), _matchingUpdatedAt.AddDays(-1), _matchingFinishedAt.AddDays(-1)), + CreateWorkflowInstance("unfinished", _matchingCreatedAt.AddDays(-1), _matchingUpdatedAt.AddDays(-1), null) + }.AsQueryable(); + } + + [Theory] + [InlineData(nameof(WorkflowInstance.CreatedAt))] + [InlineData(nameof(WorkflowInstance.UpdatedAt))] + [InlineData(nameof(WorkflowInstance.FinishedAt))] + public void Apply_WithAllowedTimestampColumn_FiltersByTimestamp(string column) + { + var filter = new WorkflowInstanceFilter + { + TimestampFilters = + [ + new() + { + Column = column, + Operator = TimestampFilterOperator.GreaterThanOrEqual, + Timestamp = GetMatchingTimestamp(column) + } + ] + }; + + var result = filter.Apply(_workflowInstances).ToList(); + + var workflowInstance = Assert.Single(result); + Assert.Equal("matching", workflowInstance.Id); + } + + [Fact] + public void Apply_WithInjectedTimestampColumn_RejectsColumnBeforeDynamicLinqParsesIt() + { + var filter = new WorkflowInstanceFilter + { + TimestampFilters = + [ + new() + { + Column = "CreatedAt == @0 || Id != null", + Operator = TimestampFilterOperator.Is, + Timestamp = _matchingCreatedAt + } + ] + }; + + var exception = Assert.Throws(() => filter.Apply(_workflowInstances).ToList()); + + Assert.Contains("Invalid timestamp filter column", exception.Message); + Assert.Contains("CreatedAt, UpdatedAt, FinishedAt", exception.Message); + } + + [Fact] + public void ValidateTimestampFilters_WithMissingColumn_ReturnsClearValidationError() + { + var errors = WorkflowInstanceFilter.ValidateTimestampFilters( + [ + new() + { + Column = " ", + Operator = TimestampFilterOperator.Is, + Timestamp = _matchingCreatedAt + } + ]).ToList(); + + var error = Assert.Single(errors); + Assert.Equal("Timestamp filter column must be specified.", error); + } + + private DateTimeOffset GetMatchingTimestamp(string column) => column switch + { + nameof(WorkflowInstance.CreatedAt) => _matchingCreatedAt, + nameof(WorkflowInstance.UpdatedAt) => _matchingUpdatedAt, + nameof(WorkflowInstance.FinishedAt) => _matchingFinishedAt, + _ => throw new ArgumentOutOfRangeException(nameof(column), column, null) + }; + + private static WorkflowInstance CreateWorkflowInstance(string id, DateTimeOffset createdAt, DateTimeOffset updatedAt, DateTimeOffset? finishedAt) + { + return new() + { + Id = id, + DefinitionId = "definition", + DefinitionVersionId = "definition-version", + Version = 1, + WorkflowState = new WorkflowState + { + Id = id, + DefinitionId = "definition", + DefinitionVersionId = "definition-version", + DefinitionVersion = 1, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + FinishedAt = finishedAt + }, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + FinishedAt = finishedAt + }; + } +}