[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
This commit is contained in:
Sipke Schoorstra 2026-05-22 15:27:13 +02:00 committed by GitHub
parent 163d6e6f8f
commit 58719078d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 439 additions and 31 deletions

View file

@ -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<SimulatedResponse>
public class SimulateResponseEndpoint(SimulateResponseSessionStore sessionStore, IOptions<SimulateResponseOptions> options) : ElsaEndpointWithoutRequest<SimulatedResponse>
{
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<int[]>(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;
}
}
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<int>();
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"}";
}
}

View file

@ -0,0 +1,47 @@
using Elsa.Resilience.Options;
using Microsoft.Extensions.Options;
namespace Elsa.Resilience.Endpoints.SimulateResponse;
public class SimulateResponseSessionStore(IOptions<SimulateResponseOptions> options, TimeProvider timeProvider)
{
private readonly Dictionary<string, SessionState> _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);
}

View file

@ -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<ResilienceOptions>();
Services.AddOptions<SimulateResponseOptions>();
Services.TryAddSingleton(TimeProvider.System);
Services
.AddSingleton<ResilienceStrategySerializer>()
.AddSingleton<SimulateResponseSessionStore>()
.AddSingleton<IActivityDescriptorModifier, ResilientActivityDescriptorModifier>()
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()
@ -90,4 +95,4 @@ public class ResilienceFeature(IModule module) : FeatureBase(module)
.AddSingleton<ITransientExceptionStrategy, DefaultTransientExceptionStrategy>()
.AddSingleton<ITransientExceptionDetector, TransientExceptionDetector>();
}
}
}

View file

@ -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;
}

View file

@ -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<ResilienceOptions>();
services.AddOptions<SimulateResponseOptions>();
services.TryAddSingleton(TimeProvider.System);
services
.AddSingleton<ResilienceStrategySerializer>()
.AddSingleton<SimulateResponseSessionStore>()
.AddSingleton<IActivityDescriptorModifier, ResilientActivityDescriptorModifier>()
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()

View file

@ -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<AuthenticationSchemeOptions, TestAuthenticationHandler>(TestAuthenticationHandler.AuthenticationScheme, _ => { });
builder.Services.AddAuthorization();
builder.Services.AddFastEndpoints(o =>
{
o.Assemblies = [typeof(ResilienceFeature).Assembly];
o.DisableAutoDiscovery = true;
});
builder.Services.AddSingleton<TimeProvider>(_timeProvider);
builder.Services.AddOptions<ResilienceOptions>();
builder.Services.AddOptions<SimulateResponseOptions>().Configure(options =>
{
options.SessionCapacity = 2;
options.SessionSlidingExpiration = TimeSpan.FromSeconds(1);
options.MaxCodes = 3;
options.MaxCodesQueryLength = 32;
options.MaxSessionIdLength = 16;
});
builder.Services.AddSingleton<ResilienceStrategySerializer>();
builder.Services.AddSingleton<SimulateResponseSessionStore>();
builder.Services.AddScoped<IRetryAttemptReader>(_ => VoidRetryAttemptReader.Instance);
builder.Services.AddScoped(_ =>
{
var catalog = Substitute.For<IResilienceStrategyCatalog>();
catalog.ListAsync(Arg.Any<CancellationToken>()).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<HttpResponseMessage> 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<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(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<AuthenticateResult> 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;