Whitelist workflow timestamp filter columns

This commit is contained in:
Sipke Schoorstra 2026-05-20 12:56:18 +02:00
parent e2e00ff235
commit 37de3404cd
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
7 changed files with 428 additions and 25 deletions

View file

@ -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
/// <inheritdoc />
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);
}
}
private bool ValidateInput(AlterationWorkflowInstanceFilter filter)
{
foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(filter.TimestampFilters))
{
AddError(error);
return false;
}
return true;
}
}

View file

@ -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<AlterationPlanParams, Response>
/// <inheritdoc />
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<AlterationPlanParams, Response>
var response = new Response(planId);
await Send.OkAsync(response, cancellationToken);
}
}
private bool ValidateInput(AlterationPlanParams planParams)
{
foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(planParams.Filter.TimestampFilters))
{
AddError(error);
return false;
}
return true;
}
}

View file

@ -84,27 +84,10 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint<Request, Respon
return false;
}
var columnWhitelist = new[]
foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(request.TimestampFilters))
{
"CreatedAt", "UpdatedAt", "FinishedAt"
};
if (request.TimestampFilters?.Any() == true)
{
foreach (var timestampFilter in request.TimestampFilters)
{
if (string.IsNullOrWhiteSpace(timestampFilter.Column))
{
AddError("Column must be specified.");
return false;
}
if (!columnWhitelist.Contains(timestampFilter.Column))
{
AddError($"Invalid column '{timestampFilter.Column}'.");
return false;
}
}
AddError(error);
return false;
}
return true;
@ -159,4 +142,4 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint<Request, Respon
}
}
}
}
}

View file

@ -11,6 +11,18 @@ namespace Elsa.Workflows.Management.Filters;
/// </summary>
public class WorkflowInstanceFilter
{
private static readonly string[] TimestampFilterColumns =
[
nameof(WorkflowInstance.CreatedAt),
nameof(WorkflowInstance.UpdatedAt),
nameof(WorkflowInstance.FinishedAt)
];
/// <summary>
/// The workflow instance timestamp columns that can be used by <see cref="TimestampFilters"/>.
/// </summary>
public static IReadOnlyCollection<string> AllowedTimestampFilterColumns { get; } = Array.AsReadOnly(TimestampFilterColumns);
/// <summary>
/// Filter workflow instances by ID.
/// </summary>
@ -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;
}
}
/// <summary>
/// Validates timestamp filters.
/// </summary>
public static IEnumerable<string> ValidateTimestampFilters(IEnumerable<TimestampFilter>? timestampFilters)
{
if (timestampFilters == null)
yield break;
foreach (var timestampFilter in timestampFilters)
{
if (!TryNormalizeTimestampFilterColumn(timestampFilter.Column, out _, out var error))
yield return error;
}
}
/// <summary>
/// Resolves a timestamp filter column to its canonical workflow instance property name.
/// </summary>
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));
}
}

View file

@ -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<IWorkflowInstanceFinder>());
builder.Services.AddSingleton(Substitute.For<IAlterationPlanScheduler>());
builder.Services.AddSingleton(Substitute.For<IAlterationRunner>());
builder.Services.AddSingleton(Substitute.For<IAlteredWorkflowDispatcher>());
builder.Services.AddSingleton(Substitute.For<IAlterationPlanStore>());
builder.Services.AddSingleton(Substitute.For<IAlterationJobStore>());
builder.Services.AddSingleton(Substitute.For<IWorkflowDispatcher>());
builder.Services.AddSingleton(Substitute.For<IWorkflowInstanceStore>());
builder.Services.AddSingleton(Substitute.For<IIdentityGenerator>());
builder.Services.AddSingleton(Substitute.For<ISystemClock>());
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;
}
}

View file

@ -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<IWorkflowInstanceFinder>();
_workflowInstanceStore = services.GetRequiredService<IWorkflowInstanceStore>();
}
[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<ArgumentException>(() => _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
};
}
}

View file

@ -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<WorkflowInstance> _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<ArgumentException>(() => 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
};
}
}