From 7f1f1e50e91d013809cbe4e185d115d0f4de7589 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 18:12:37 -0700 Subject: [PATCH] Skip HTTP workflow route matching outside base path (#7757) * Skip HTTP workflow route matching outside base path * Handle tenant-prefixed HTTP workflow base paths Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Narrow tenant-prefixed HTTP base-path matching Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Fix sibling-prefix HTTP base-path matching * Limit HTTP base-path precheck depth * Use resolved tenant paths for HTTP routing --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --- .../Middleware/HttpWorkflowsMiddleware.cs | 68 +++++-- .../HttpWorkflowsMiddlewareTests.cs | 190 ++++++++++++++++++ 2 files changed, 245 insertions(+), 13 deletions(-) diff --git a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs index 53d529d4c..6cf80c47e 100644 --- a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs @@ -40,22 +40,17 @@ public class HttpWorkflowsMiddleware(RequestDelegate next) IHttpWorkflowLookupService httpWorkflowLookupService) { var path = httpContext.Request.Path.Value!.NormalizeRoute(); - var matchingPath = GetMatchingRoute(serviceProvider, path).Route; var basePath = options.Value.BasePath?.ToString().NormalizeRoute(); - // If the request path does not match the configured base path to handle workflows, then skip. - if (!string.IsNullOrWhiteSpace(basePath)) + if (!string.IsNullOrWhiteSpace(basePath) && !IsBasePathMatch(path, basePath)) { - if (!path.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) - { - await next(httpContext); - return; - } - - // Strip the base path. - matchingPath = matchingPath[basePath.Length..]; + await next(httpContext); + return; } + var matchingPath = GetMatchingRoute(serviceProvider, path).Route; + matchingPath = TryStripBasePath(matchingPath, basePath) ?? matchingPath; + // Graceful-shutdown gate: when the runtime is paused or draining, we don't accept new HTTP-triggered work. // The ingress source registry visibility is provided by HttpTriggerIngressSource — this is the actual mechanism. var quiescenceSignal = serviceProvider.GetService(); @@ -277,11 +272,58 @@ public class HttpWorkflowsMiddleware(RequestDelegate next) }; var matchingRoute = matchingRouteQuery.FirstOrDefault(); - var routeTemplate = matchingRoute?.route ?? new HttpRouteData(path); - return routeTemplate; + return matchingRoute?.route ?? new HttpRouteData(path); } + private static string? TryStripBasePath(string route, string? basePath) + { + if (string.IsNullOrWhiteSpace(basePath) || basePath == "/") + return route; + + var routeSegments = GetRouteSegments(route); + var basePathSegments = GetRouteSegments(basePath); + var basePathIndex = FindSegmentSequence(routeSegments, basePathSegments); + + if (basePathIndex != 0) + return null; + + var remainingSegments = routeSegments.Skip(basePathIndex + basePathSegments.Length); + return remainingSegments.Any() ? $"/{string.Join('/', remainingSegments)}" : "/"; + } + + private static bool IsBasePathMatch(string route, string basePath) => + basePath == "/" || + string.Equals(route, basePath, StringComparison.OrdinalIgnoreCase) || + route.StartsWith($"{basePath}/", StringComparison.OrdinalIgnoreCase); + + private static int FindSegmentSequence(string[] routeSegments, string[] candidateSegments) + { + if (candidateSegments.Length == 0) + return 0; + + for (var startIndex = 0; startIndex <= routeSegments.Length - candidateSegments.Length; startIndex++) + { + var isMatch = true; + + for (var candidateIndex = 0; candidateIndex < candidateSegments.Length; candidateIndex++) + { + if (!string.Equals(routeSegments[startIndex + candidateIndex], candidateSegments[candidateIndex], StringComparison.OrdinalIgnoreCase)) + { + isMatch = false; + break; + } + } + + if (isMatch) + return startIndex; + } + + return -1; + } + + private static string[] GetRouteSegments(string route) => route.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + private async Task GetCorrelationIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, CancellationToken cancellationToken) { var correlationIdSelectors = serviceProvider.GetServices(); diff --git a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs index 4d2f9616d..7338b9aaf 100644 --- a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs +++ b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs @@ -10,6 +10,7 @@ using Elsa.Workflows.Runtime.Filters; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; namespace Elsa.Http.UnitTests.Middleware; @@ -55,6 +56,184 @@ public class HttpWorkflowsMiddlewareTests Assert.False(filter.TenantAgnostic); } + [Fact] + public async Task InvokeAsync_WithConfiguredBasePathAndNonMatchingPath_SkipsRouteMatchingAndCallsNext() + { + var nextCalled = false; + var middleware = new HttpWorkflowsMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + httpContext.Request.Path = "/health"; + + await middleware.InvokeAsync( + httpContext, + serviceProvider, + Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }), + new EmptyHttpWorkflowLookupService()); + + Assert.True(nextCalled); + } + + [Fact] + public async Task InvokeAsync_WithSiblingPrefixPath_SkipsRouteMatchingAndCallsNext() + { + var nextCalled = false; + var middleware = new HttpWorkflowsMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + httpContext.Request.Path = "/workflows-v2/status"; + + await middleware.InvokeAsync( + httpContext, + serviceProvider, + Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }), + new EmptyHttpWorkflowLookupService()); + + Assert.True(nextCalled); + } + + [Fact] + public async Task InvokeAsync_WithMultiplePrefixSegmentsBeforeBasePath_SkipsRouteMatchingAndCallsNext() + { + var nextCalled = false; + var routeMatcher = Substitute.For(); + var middleware = new HttpWorkflowsMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var serviceProvider = new ServiceCollection() + .AddSingleton(routeMatcher) + .AddSingleton(new ListRouteTable([new("/api/v1/workflows/status")])) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + httpContext.Request.Path = "/api/v1/workflows/status"; + + await middleware.InvokeAsync( + httpContext, + serviceProvider, + Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }), + new EmptyHttpWorkflowLookupService()); + + Assert.True(nextCalled); + routeMatcher.DidNotReceive().Match(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_WithNonTenantPrefixedBasePathSegment_CallsNext() + { + var nextCalled = false; + var routeMatcher = Substitute.For(); + var middleware = new HttpWorkflowsMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var serviceProvider = new ServiceCollection() + .AddSingleton(routeMatcher) + .AddSingleton(new ListRouteTable([new("/{tenantPrefix}/workflows/colliding")])) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + httpContext.Request.Path = "/api/workflows/colliding"; + + await middleware.InvokeAsync( + httpContext, + serviceProvider, + Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }), + new EmptyHttpWorkflowLookupService()); + + Assert.True(nextCalled); + routeMatcher.DidNotReceive().Match(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_WithConfiguredBasePathAndMatchingPath_StillResolvesRoute() + { + var routeMatcher = Substitute.For(); + routeMatcher.Match("/workflows/colliding", "/workflows/colliding").Returns(new RouteValueDictionary()); + var bookmarkStore = new CapturingBookmarkStore(CurrentTenantId, CreateCollidingHttpEndpointBookmarks()); + var serviceProvider = new ServiceCollection() + .AddSingleton(bookmarkStore) + .AddSingleton(routeMatcher) + .AddSingleton(new ListRouteTable([new("/workflows/colliding")])) + .AddSingleton() + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + httpContext.Request.Path = "/workflows/colliding"; + httpContext.Request.Method = HttpMethod.Get.Method; + + await _middleware.InvokeAsync( + httpContext, + serviceProvider, + Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }), + new EmptyHttpWorkflowLookupService()); + + routeMatcher.Received(1).Match("/workflows/colliding", "/workflows/colliding"); + Assert.NotNull(bookmarkStore.LastFilter); + } + + [Fact] + public async Task InvokeAsync_WithResolvedTenantPath_StillResolvesRoute() + { + var nextCalled = false; + var routeMatcher = Substitute.For(); + var stimulusHasher = new CapturingStimulusHasher(); + var middleware = new HttpWorkflowsMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + routeMatcher.Match("/workflows/colliding", "/workflows/colliding").Returns(new RouteValueDictionary()); + var bookmarkStore = new CapturingBookmarkStore(CurrentTenantId, CreateCollidingHttpEndpointBookmarks()); + var serviceProvider = new ServiceCollection() + .AddSingleton(bookmarkStore) + .AddSingleton(routeMatcher) + .AddSingleton(new ListRouteTable([new("/workflows/colliding")])) + .AddSingleton(stimulusHasher) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + httpContext.Request.PathBase = "/acme"; + httpContext.Request.Path = "/workflows/colliding"; + httpContext.Request.Method = HttpMethod.Get.Method; + + await middleware.InvokeAsync( + httpContext, + serviceProvider, + Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }), + new EmptyHttpWorkflowLookupService()); + + Assert.False(nextCalled); + routeMatcher.Received(1).Match("/workflows/colliding", "/workflows/colliding"); + Assert.Equal("/colliding", stimulusHasher.LastPayload?.Path); + Assert.NotNull(bookmarkStore.LastFilter); + } + private static IEnumerable CreateCollidingHttpEndpointBookmarks() { yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId); @@ -143,6 +322,17 @@ public class HttpWorkflowsMiddlewareTests public string Hash(string stimulusName, object? payload = null, string? activityInstanceId = null) => BookmarkHash; } + private class CapturingStimulusHasher : IStimulusHasher + { + public HttpEndpointBookmarkPayload? LastPayload { get; private set; } + + public string Hash(string stimulusName, object? payload = null, string? activityInstanceId = null) + { + LastPayload = payload as HttpEndpointBookmarkPayload; + return BookmarkHash; + } + } + private class ExactRouteMatcher : IRouteMatcher { public RouteValueDictionary? Match(string routeTemplate, string route) => routeTemplate == route ? new() : null;