From 58719078d0b5df6de1abea349ffe692f1b86ca57 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 22 May 2026 15:27:13 +0200 Subject: [PATCH] [codex] Secure Resilience simulate response endpoint (#7505) * Secure simulate response endpoint * Address simulate response review feedback * Clamp simulate response session index * Isolate simulate response security tests * Address simulate response review feedback * Address simulate response review feedback --- .../Endpoints/SimulateResponse/Endpoint.cs | 164 +++++++++--- .../SimulateResponseSessionStore.cs | 47 ++++ .../Features/ResilienceFeature.cs | 7 +- .../Options/SimulateResponseOptions.cs | 10 + .../ShellFeatures/ResilienceFeature.cs | 5 + .../SimulateResponseEndpointTests.cs | 237 ++++++++++++++++++ 6 files changed, 439 insertions(+), 31 deletions(-) create mode 100644 src/modules/Elsa.Resilience/Endpoints/SimulateResponse/SimulateResponseSessionStore.cs create mode 100644 src/modules/Elsa.Resilience/Options/SimulateResponseOptions.cs create mode 100644 test/integration/Elsa.Resilience.IntegrationTests/SimulateResponseEndpointTests.cs diff --git a/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Endpoint.cs b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Endpoint.cs index 39594d27f..4e004b580 100644 --- a/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Endpoint.cs +++ b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/Endpoint.cs @@ -1,55 +1,159 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; -using FastEndpoints; -using Microsoft.Extensions.Caching.Memory; +using Elsa.Abstractions; +using Elsa.Resilience.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; using static Elsa.Resilience.Endpoints.SimulateResponse.StatusCodeMessageLookup; namespace Elsa.Resilience.Endpoints.SimulateResponse; -public class SimulateResponseEndpoint(IMemoryCache memoryCache) : EndpointWithoutRequest +public class SimulateResponseEndpoint(SimulateResponseSessionStore sessionStore, IOptions options) : ElsaEndpointWithoutRequest { - private static readonly TimeSpan SlidingExpirationTimeSpan = TimeSpan.FromMinutes(15); + private static readonly int[] DefaultCodes = [429, 503, 200]; + private readonly SimulateResponseOptions _options = options.Value; public override void Configure() { Get("/simulate-response"); - AllowAnonymous(); + ConfigurePermissions("exec:*", "exec:resilience", "exec:resilience:simulate-response"); } public override async Task HandleAsync(CancellationToken ct) { - var sessionId = HttpContext.Request.Query["sessionId"].FirstOrDefault() ?? "default"; - var codes = GetCodes(); - var cacheKey = $"status-simulation-session-{sessionId}"; - var nextIndex = memoryCache.GetOrCreate(cacheKey, entry => + if (!TryGetSessionId(out var sessionId, out var error) || !TryGetCodes(out var codes, out error)) { - entry.SlidingExpiration = SlidingExpirationTimeSpan; - return 0; - }); + AddError(error); + await Send.ErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } - var currentCode = nextIndex < codes.Length ? codes[nextIndex] : codes[^1]; + var scopedSessionId = CreateScopedSessionId(sessionId); + if (!sessionStore.TryGetNextIndex(scopedSessionId, codes.Length, out var nextIndex)) + { + AddError($"The maximum number of active simulate-response sessions ({_options.SessionCapacity}) has been reached."); + await Send.ErrorsAsync(StatusCodes.Status429TooManyRequests, ct); + return; + } + + var currentCode = codes[nextIndex]; var message = StatusMessages.TryGetValue(currentCode, out var reason) ? reason : $"Status Code {currentCode}"; - if (nextIndex + 1 >= codes.Length) - { - memoryCache.Remove(cacheKey); - } - else - { - memoryCache.Set(cacheKey, nextIndex + 1, new MemoryCacheEntryOptions - { - SlidingExpiration = SlidingExpirationTimeSpan - }); - } - await Send.ResponseAsync(new(message), currentCode, ct); } - private int[] GetCodes() + private bool TryGetSessionId(out string sessionId, out string error) { - var codesParam = HttpContext.Request.Query["codes"].FirstOrDefault(); - int[] defaultCodes = [429, 503, 200]; - return string.IsNullOrWhiteSpace(codesParam) ? defaultCodes : JsonSerializer.Deserialize(codesParam)!; + sessionId = "default"; + error = ""; + var sessionIdParam = HttpContext.Request.Query["sessionId"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(sessionIdParam)) + return true; + + if (sessionIdParam.Length > _options.MaxSessionIdLength) + { + error = $"The sessionId query parameter must be {_options.MaxSessionIdLength} characters or fewer."; + return false; + } + + sessionId = sessionIdParam; + return true; } -} \ No newline at end of file + + private bool TryGetCodes(out int[] codes, out string error) + { + codes = DefaultCodes; + error = ""; + var codesParam = HttpContext.Request.Query["codes"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(codesParam)) + return true; + + if (codesParam.Length > _options.MaxCodesQueryLength) + { + error = $"The codes query parameter must be {_options.MaxCodesQueryLength} characters or fewer."; + return false; + } + + try + { + using var document = JsonDocument.Parse(codesParam); + + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + error = "The codes query parameter must be a JSON array of HTTP status codes."; + return false; + } + + var parsedCodes = new List(); + foreach (var codeElement in document.RootElement.EnumerateArray()) + { + if (parsedCodes.Count >= _options.MaxCodes) + { + error = $"The codes query parameter can contain at most {_options.MaxCodes} status codes."; + return false; + } + + if (codeElement.ValueKind != JsonValueKind.Number || !codeElement.TryGetInt32(out var code) || code is < 100 or > 599) + { + error = "The codes query parameter must contain HTTP status codes between 100 and 599."; + return false; + } + + parsedCodes.Add(code); + } + + if (parsedCodes.Count == 0) + { + error = "The codes query parameter must contain at least one status code."; + return false; + } + + codes = parsedCodes.ToArray(); + return true; + } + catch (JsonException) + { + error = "The codes query parameter must be valid JSON."; + return false; + } + } + + private string CreateScopedSessionId(string sessionId) + { + var key = $"{GetAuthenticatedIdentityKey()}\0{sessionId}"; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))); + } + + private string GetAuthenticatedIdentityKey() + { + var user = HttpContext.User; + var identityClaim = user.FindFirst(ClaimTypes.NameIdentifier) + ?? user.FindFirst("sub") + ?? user.FindFirst("client_id") + ?? user.FindFirst("name") + ?? user.FindFirst(ClaimTypes.Name); + + if (identityClaim != null) + return $"{identityClaim.Type}:{identityClaim.Value}"; + + if (!string.IsNullOrWhiteSpace(user.Identity?.Name)) + return $"name:{user.Identity.Name}"; + + var claimsKey = string.Join('\u001e', user.Claims + .Where(x => x.Type != "permissions") + .OrderBy(x => x.Type, StringComparer.Ordinal) + .ThenBy(x => x.Issuer, StringComparer.Ordinal) + .ThenBy(x => x.Value, StringComparer.Ordinal) + .Select(x => $"{x.Type}:{x.Issuer}:{x.Value}")); + + return !string.IsNullOrEmpty(claimsKey) + ? claimsKey + : $"auth:{user.Identity?.AuthenticationType ?? "unknown"}"; + } +} diff --git a/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/SimulateResponseSessionStore.cs b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/SimulateResponseSessionStore.cs new file mode 100644 index 000000000..151c9a850 --- /dev/null +++ b/src/modules/Elsa.Resilience/Endpoints/SimulateResponse/SimulateResponseSessionStore.cs @@ -0,0 +1,47 @@ +using Elsa.Resilience.Options; +using Microsoft.Extensions.Options; + +namespace Elsa.Resilience.Endpoints.SimulateResponse; + +public class SimulateResponseSessionStore(IOptions options, TimeProvider timeProvider) +{ + private readonly Dictionary _sessions = new(StringComparer.Ordinal); + private readonly object _lock = new(); + private readonly SimulateResponseOptions _options = options.Value; + + public bool TryGetNextIndex(string sessionId, int statusCodeCount, out int nextIndex) + { + if (statusCodeCount <= 0) + throw new ArgumentOutOfRangeException(nameof(statusCodeCount), "Status code count must be greater than zero."); + + var now = timeProvider.GetUtcNow(); + var expiresAt = now.Add(_options.SessionSlidingExpiration); + + lock (_lock) + { + PruneExpired(now); + + nextIndex = _sessions.TryGetValue(sessionId, out var state) ? Math.Min(state.NextIndex, statusCodeCount - 1) : 0; + + if (nextIndex + 1 >= statusCodeCount) + { + _sessions.Remove(sessionId); + return true; + } + + if (!_sessions.ContainsKey(sessionId) && _sessions.Count >= _options.SessionCapacity) + return false; + + _sessions[sessionId] = new SessionState(nextIndex + 1, expiresAt); + return true; + } + } + + private void PruneExpired(DateTimeOffset now) + { + foreach (var session in _sessions.Where(x => x.Value.ExpiresAt <= now).Select(x => x.Key).ToList()) + _sessions.Remove(session); + } + + private sealed record SessionState(int NextIndex, DateTimeOffset ExpiresAt); +} diff --git a/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs index cefc31421..5cc8f6d3e 100644 --- a/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs +++ b/src/modules/Elsa.Resilience/Features/ResilienceFeature.cs @@ -2,6 +2,7 @@ using Elsa.Expressions.Options; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Services; +using Elsa.Resilience.Endpoints.SimulateResponse; using Elsa.Resilience.Entities; using Elsa.Resilience.Modifiers; using Elsa.Resilience.Options; @@ -10,6 +11,7 @@ using Elsa.Resilience.Serialization; using Elsa.Resilience.StrategySources; using Elsa.Workflows; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Elsa.Resilience.Features; @@ -69,9 +71,12 @@ public class ResilienceFeature(IModule module) : FeatureBase(module) public override void Apply() { Services.AddOptions(); + Services.AddOptions(); + Services.TryAddSingleton(TimeProvider.System); Services .AddSingleton() + .AddSingleton() .AddSingleton() .AddScoped() .AddScoped() @@ -90,4 +95,4 @@ public class ResilienceFeature(IModule module) : FeatureBase(module) .AddSingleton() .AddSingleton(); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Resilience/Options/SimulateResponseOptions.cs b/src/modules/Elsa.Resilience/Options/SimulateResponseOptions.cs new file mode 100644 index 000000000..f57119a99 --- /dev/null +++ b/src/modules/Elsa.Resilience/Options/SimulateResponseOptions.cs @@ -0,0 +1,10 @@ +namespace Elsa.Resilience.Options; + +public class SimulateResponseOptions +{ + public int SessionCapacity { get; set; } = 1_000; + public TimeSpan SessionSlidingExpiration { get; set; } = TimeSpan.FromMinutes(15); + public int MaxSessionIdLength { get; set; } = 128; + public int MaxCodesQueryLength { get; set; } = 1_024; + public int MaxCodes { get; set; } = 32; +} diff --git a/src/modules/Elsa.Resilience/ShellFeatures/ResilienceFeature.cs b/src/modules/Elsa.Resilience/ShellFeatures/ResilienceFeature.cs index 6788f3bfa..4e57ce778 100644 --- a/src/modules/Elsa.Resilience/ShellFeatures/ResilienceFeature.cs +++ b/src/modules/Elsa.Resilience/ShellFeatures/ResilienceFeature.cs @@ -2,6 +2,7 @@ using CShells.FastEndpoints.Features; using CShells.Features; using Elsa.Expressions.Options; using Elsa.Extensions; +using Elsa.Resilience.Endpoints.SimulateResponse; using Elsa.Resilience.Entities; using Elsa.Resilience.Modifiers; using Elsa.Resilience.Options; @@ -10,6 +11,7 @@ using Elsa.Resilience.Serialization; using Elsa.Resilience.StrategySources; using Elsa.Workflows; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Elsa.Resilience.ShellFeatures; @@ -26,9 +28,12 @@ public class ResilienceFeature : IFastEndpointsShellFeature }); services.AddOptions(); + services.AddOptions(); + services.TryAddSingleton(TimeProvider.System); services .AddSingleton() + .AddSingleton() .AddSingleton() .AddScoped() .AddScoped() diff --git a/test/integration/Elsa.Resilience.IntegrationTests/SimulateResponseEndpointTests.cs b/test/integration/Elsa.Resilience.IntegrationTests/SimulateResponseEndpointTests.cs new file mode 100644 index 000000000..d1e5dfb6d --- /dev/null +++ b/test/integration/Elsa.Resilience.IntegrationTests/SimulateResponseEndpointTests.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Elsa; +using Elsa.Resilience.Endpoints.SimulateResponse; +using Elsa.Resilience.Features; +using Elsa.Resilience.Options; +using Elsa.Resilience.Serialization; +using FastEndpoints; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Elsa.Resilience.IntegrationTests; + +[Collection(nameof(EndpointSecurityCollection))] +public class SimulateResponseEndpointTests : IAsyncLifetime +{ + private readonly TestTimeProvider _timeProvider = new(); + private WebApplication? _app; + private bool _wasSecurityEnabled; + + private HttpClient HttpClient { get; set; } = null!; + + public async Task InitializeAsync() + { + _wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled; + EndpointSecurityOptions.SecurityIsEnabled = true; + + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddAuthentication(TestAuthenticationHandler.AuthenticationScheme) + .AddScheme(TestAuthenticationHandler.AuthenticationScheme, _ => { }); + builder.Services.AddAuthorization(); + builder.Services.AddFastEndpoints(o => + { + o.Assemblies = [typeof(ResilienceFeature).Assembly]; + o.DisableAutoDiscovery = true; + }); + builder.Services.AddSingleton(_timeProvider); + builder.Services.AddOptions(); + builder.Services.AddOptions().Configure(options => + { + options.SessionCapacity = 2; + options.SessionSlidingExpiration = TimeSpan.FromSeconds(1); + options.MaxCodes = 3; + options.MaxCodesQueryLength = 32; + options.MaxSessionIdLength = 16; + }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(_ => VoidRetryAttemptReader.Instance); + builder.Services.AddScoped(_ => + { + var catalog = Substitute.For(); + catalog.ListAsync(Arg.Any()).Returns([]); + return catalog; + }); + + _app = builder.Build(); + _app.UseAuthentication(); + _app.UseAuthorization(); + _app.UseFastEndpoints(); + + await _app.StartAsync(); + HttpClient = _app.GetTestClient(); + } + + public async Task DisposeAsync() + { + EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled; + HttpClient.Dispose(); + + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + [Fact] + public async Task Get_WhenAnonymousAndSecurityEnabled_DoesNotCreateSessionState() + { + var response = await HttpClient.GetAsync("/simulate-response?sessionId=anon&codes=[500,200]"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + + var authenticatedResponse = await GetAuthenticatedAsync("/simulate-response?sessionId=anon&codes=[500,200]"); + Assert.Equal(HttpStatusCode.InternalServerError, authenticatedResponse.StatusCode); + } + + [Fact] + public async Task Get_WhenCodesAreMalformed_ReturnsBadRequest() + { + var response = await GetAuthenticatedAsync("/simulate-response?codes=not-json"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Get_WhenSessionIdExceedsMaxLength_ReturnsBadRequest() + { + var response = await GetAuthenticatedAsync("/simulate-response?sessionId=exceeds-sixteen-chars"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Get_WhenSessionIdIsAtMaxLength_AcceptsRequest() + { + var response = await GetAuthenticatedAsync("/simulate-response?sessionId=1234567890123456&codes=[200]"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Get_WhenCodesQueryExceedsMaxLength_ReturnsBadRequest() + { + var response = await GetAuthenticatedAsync($"/simulate-response?codes={new string('1', 33)}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Get_WhenCodesExceedMaxCount_ReturnsBadRequest() + { + var response = await GetAuthenticatedAsync("/simulate-response?codes=[500,503,200,201]"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData(99)] + [InlineData(600)] + public async Task Get_WhenCodesAreOutOfRange_ReturnsBadRequest(int statusCode) + { + var response = await GetAuthenticatedAsync($"/simulate-response?codes=[{statusCode}]"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Get_WhenSessionCapacityIsReached_ReturnsTooManyRequestsUntilStateExpires() + { + Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=first&codes=[500,200]")).StatusCode); + Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=second&codes=[500,200]")).StatusCode); + + var rejected = await GetAuthenticatedAsync("/simulate-response?sessionId=third&codes=[500,200]"); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + + _timeProvider.Advance(TimeSpan.FromSeconds(2)); + + var acceptedAfterExpiration = await GetAuthenticatedAsync("/simulate-response?sessionId=third&codes=[500,200]"); + Assert.Equal(HttpStatusCode.InternalServerError, acceptedAfterExpiration.StatusCode); + } + + [Fact] + public async Task Get_WhenExistingSessionUsesShorterCodes_DoesNotReadBeyondCodes() + { + Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=reused&codes=[500,503,200]")).StatusCode); + + var response = await GetAuthenticatedAsync("/simulate-response?sessionId=reused&codes=[200]"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Get_WhenDifferentIdentitiesUseSameSessionId_TracksSessionsIndependently() + { + Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=shared&codes=[500,200]", "alice")).StatusCode); + Assert.Equal(HttpStatusCode.InternalServerError, (await GetAuthenticatedAsync("/simulate-response?sessionId=shared&codes=[500,200]", "bob")).StatusCode); + + var response = await GetAuthenticatedAsync("/simulate-response?sessionId=shared&codes=[500,200]", "alice"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private async Task GetAuthenticatedAsync(string requestUri, string identity = "test-user") + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Add(TestAuthenticationHandler.PermissionHeader, "*"); + request.Headers.Add(TestAuthenticationHandler.IdentityHeader, identity); + return await HttpClient.SendAsync(request); + } + + private sealed class TestTimeProvider : TimeProvider + { + private DateTimeOffset _now = DateTimeOffset.Parse("2026-05-20T00:00:00Z"); + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan timeSpan) + { + _now = _now.Add(timeSpan); + } + } + + private sealed class TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) + { + public const string AuthenticationScheme = "Test"; + public const string IdentityHeader = "X-Test-Identity"; + public const string PermissionHeader = "X-Test-Permissions"; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(PermissionHeader, out var permissionHeader)) + return Task.FromResult(AuthenticateResult.NoResult()); + + var identity = Request.Headers.TryGetValue(IdentityHeader, out var identityHeader) + ? identityHeader.FirstOrDefault() + : null; + var claims = permissionHeader + .SelectMany(x => x?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []) + .Select(x => new Claim("permissions", x)) + .ToList(); + + claims.Add(new Claim(ClaimTypes.NameIdentifier, identity ?? "test-user")); + + var claimsIdentity = new ClaimsIdentity(claims, AuthenticationScheme); + var principal = new ClaimsPrincipal(claimsIdentity); + var ticket = new AuthenticationTicket(principal, AuthenticationScheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} + +[CollectionDefinition(nameof(EndpointSecurityCollection), DisableParallelization = true)] +public class EndpointSecurityCollection;