Skip to content

Commit 5871907

Browse files
authored
[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
1 parent 163d6e6 commit 5871907

6 files changed

Lines changed: 434 additions & 26 deletions

File tree

Lines changed: 129 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,159 @@
1+
using System.Security.Claims;
2+
using System.Security.Cryptography;
3+
using System.Text;
14
using System.Text.Json;
2-
using FastEndpoints;
3-
using Microsoft.Extensions.Caching.Memory;
5+
using Elsa.Abstractions;
6+
using Elsa.Resilience.Options;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.Extensions.Options;
49
using static Elsa.Resilience.Endpoints.SimulateResponse.StatusCodeMessageLookup;
510

611
namespace Elsa.Resilience.Endpoints.SimulateResponse;
712

8-
public class SimulateResponseEndpoint(IMemoryCache memoryCache) : EndpointWithoutRequest<SimulatedResponse>
13+
public class SimulateResponseEndpoint(SimulateResponseSessionStore sessionStore, IOptions<SimulateResponseOptions> options) : ElsaEndpointWithoutRequest<SimulatedResponse>
914
{
10-
private static readonly TimeSpan SlidingExpirationTimeSpan = TimeSpan.FromMinutes(15);
15+
private static readonly int[] DefaultCodes = [429, 503, 200];
16+
private readonly SimulateResponseOptions _options = options.Value;
1117

1218
public override void Configure()
1319
{
1420
Get("/simulate-response");
15-
AllowAnonymous();
21+
ConfigurePermissions("exec:*", "exec:resilience", "exec:resilience:simulate-response");
1622
}
1723

1824
public override async Task HandleAsync(CancellationToken ct)
1925
{
20-
var sessionId = HttpContext.Request.Query["sessionId"].FirstOrDefault() ?? "default";
21-
var codes = GetCodes();
22-
var cacheKey = $"status-simulation-session-{sessionId}";
23-
var nextIndex = memoryCache.GetOrCreate(cacheKey, entry =>
26+
if (!TryGetSessionId(out var sessionId, out var error) || !TryGetCodes(out var codes, out error))
2427
{
25-
entry.SlidingExpiration = SlidingExpirationTimeSpan;
26-
return 0;
27-
});
28+
AddError(error);
29+
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, ct);
30+
return;
31+
}
2832

29-
var currentCode = nextIndex < codes.Length ? codes[nextIndex] : codes[^1];
33+
var scopedSessionId = CreateScopedSessionId(sessionId);
34+
if (!sessionStore.TryGetNextIndex(scopedSessionId, codes.Length, out var nextIndex))
35+
{
36+
AddError($"The maximum number of active simulate-response sessions ({_options.SessionCapacity}) has been reached.");
37+
await Send.ErrorsAsync(StatusCodes.Status429TooManyRequests, ct);
38+
return;
39+
}
40+
41+
var currentCode = codes[nextIndex];
3042
var message = StatusMessages.TryGetValue(currentCode, out var reason)
3143
? reason
3244
: $"Status Code {currentCode}";
3345

34-
if (nextIndex + 1 >= codes.Length)
46+
await Send.ResponseAsync(new(message), currentCode, ct);
47+
}
48+
49+
private bool TryGetSessionId(out string sessionId, out string error)
50+
{
51+
sessionId = "default";
52+
error = "";
53+
var sessionIdParam = HttpContext.Request.Query["sessionId"].FirstOrDefault();
54+
55+
if (string.IsNullOrWhiteSpace(sessionIdParam))
56+
return true;
57+
58+
if (sessionIdParam.Length > _options.MaxSessionIdLength)
59+
{
60+
error = $"The sessionId query parameter must be {_options.MaxSessionIdLength} characters or fewer.";
61+
return false;
62+
}
63+
64+
sessionId = sessionIdParam;
65+
return true;
66+
}
67+
68+
private bool TryGetCodes(out int[] codes, out string error)
69+
{
70+
codes = DefaultCodes;
71+
error = "";
72+
var codesParam = HttpContext.Request.Query["codes"].FirstOrDefault();
73+
74+
if (string.IsNullOrWhiteSpace(codesParam))
75+
return true;
76+
77+
if (codesParam.Length > _options.MaxCodesQueryLength)
3578
{
36-
memoryCache.Remove(cacheKey);
79+
error = $"The codes query parameter must be {_options.MaxCodesQueryLength} characters or fewer.";
80+
return false;
3781
}
38-
else
82+
83+
try
3984
{
40-
memoryCache.Set(cacheKey, nextIndex + 1, new MemoryCacheEntryOptions
85+
using var document = JsonDocument.Parse(codesParam);
86+
87+
if (document.RootElement.ValueKind != JsonValueKind.Array)
88+
{
89+
error = "The codes query parameter must be a JSON array of HTTP status codes.";
90+
return false;
91+
}
92+
93+
var parsedCodes = new List<int>();
94+
foreach (var codeElement in document.RootElement.EnumerateArray())
95+
{
96+
if (parsedCodes.Count >= _options.MaxCodes)
97+
{
98+
error = $"The codes query parameter can contain at most {_options.MaxCodes} status codes.";
99+
return false;
100+
}
101+
102+
if (codeElement.ValueKind != JsonValueKind.Number || !codeElement.TryGetInt32(out var code) || code is < 100 or > 599)
103+
{
104+
error = "The codes query parameter must contain HTTP status codes between 100 and 599.";
105+
return false;
106+
}
107+
108+
parsedCodes.Add(code);
109+
}
110+
111+
if (parsedCodes.Count == 0)
41112
{
42-
SlidingExpiration = SlidingExpirationTimeSpan
43-
});
113+
error = "The codes query parameter must contain at least one status code.";
114+
return false;
115+
}
116+
117+
codes = parsedCodes.ToArray();
118+
return true;
44119
}
120+
catch (JsonException)
121+
{
122+
error = "The codes query parameter must be valid JSON.";
123+
return false;
124+
}
125+
}
45126

46-
await Send.ResponseAsync(new(message), currentCode, ct);
127+
private string CreateScopedSessionId(string sessionId)
128+
{
129+
var key = $"{GetAuthenticatedIdentityKey()}\0{sessionId}";
130+
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)));
47131
}
48132

49-
private int[] GetCodes()
133+
private string GetAuthenticatedIdentityKey()
50134
{
51-
var codesParam = HttpContext.Request.Query["codes"].FirstOrDefault();
52-
int[] defaultCodes = [429, 503, 200];
53-
return string.IsNullOrWhiteSpace(codesParam) ? defaultCodes : JsonSerializer.Deserialize<int[]>(codesParam)!;
135+
var user = HttpContext.User;
136+
var identityClaim = user.FindFirst(ClaimTypes.NameIdentifier)
137+
?? user.FindFirst("sub")
138+
?? user.FindFirst("client_id")
139+
?? user.FindFirst("name")
140+
?? user.FindFirst(ClaimTypes.Name);
141+
142+
if (identityClaim != null)
143+
return $"{identityClaim.Type}:{identityClaim.Value}";
144+
145+
if (!string.IsNullOrWhiteSpace(user.Identity?.Name))
146+
return $"name:{user.Identity.Name}";
147+
148+
var claimsKey = string.Join('\u001e', user.Claims
149+
.Where(x => x.Type != "permissions")
150+
.OrderBy(x => x.Type, StringComparer.Ordinal)
151+
.ThenBy(x => x.Issuer, StringComparer.Ordinal)
152+
.ThenBy(x => x.Value, StringComparer.Ordinal)
153+
.Select(x => $"{x.Type}:{x.Issuer}:{x.Value}"));
154+
155+
return !string.IsNullOrEmpty(claimsKey)
156+
? claimsKey
157+
: $"auth:{user.Identity?.AuthenticationType ?? "unknown"}";
54158
}
55-
}
159+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Elsa.Resilience.Options;
2+
using Microsoft.Extensions.Options;
3+
4+
namespace Elsa.Resilience.Endpoints.SimulateResponse;
5+
6+
public class SimulateResponseSessionStore(IOptions<SimulateResponseOptions> options, TimeProvider timeProvider)
7+
{
8+
private readonly Dictionary<string, SessionState> _sessions = new(StringComparer.Ordinal);
9+
private readonly object _lock = new();
10+
private readonly SimulateResponseOptions _options = options.Value;
11+
12+
public bool TryGetNextIndex(string sessionId, int statusCodeCount, out int nextIndex)
13+
{
14+
if (statusCodeCount <= 0)
15+
throw new ArgumentOutOfRangeException(nameof(statusCodeCount), "Status code count must be greater than zero.");
16+
17+
var now = timeProvider.GetUtcNow();
18+
var expiresAt = now.Add(_options.SessionSlidingExpiration);
19+
20+
lock (_lock)
21+
{
22+
PruneExpired(now);
23+
24+
nextIndex = _sessions.TryGetValue(sessionId, out var state) ? Math.Min(state.NextIndex, statusCodeCount - 1) : 0;
25+
26+
if (nextIndex + 1 >= statusCodeCount)
27+
{
28+
_sessions.Remove(sessionId);
29+
return true;
30+
}
31+
32+
if (!_sessions.ContainsKey(sessionId) && _sessions.Count >= _options.SessionCapacity)
33+
return false;
34+
35+
_sessions[sessionId] = new SessionState(nextIndex + 1, expiresAt);
36+
return true;
37+
}
38+
}
39+
40+
private void PruneExpired(DateTimeOffset now)
41+
{
42+
foreach (var session in _sessions.Where(x => x.Value.ExpiresAt <= now).Select(x => x.Key).ToList())
43+
_sessions.Remove(session);
44+
}
45+
46+
private sealed record SessionState(int NextIndex, DateTimeOffset ExpiresAt);
47+
}

src/modules/Elsa.Resilience/Features/ResilienceFeature.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Elsa.Extensions;
33
using Elsa.Features.Abstractions;
44
using Elsa.Features.Services;
5+
using Elsa.Resilience.Endpoints.SimulateResponse;
56
using Elsa.Resilience.Entities;
67
using Elsa.Resilience.Modifiers;
78
using Elsa.Resilience.Options;
@@ -10,6 +11,7 @@
1011
using Elsa.Resilience.StrategySources;
1112
using Elsa.Workflows;
1213
using Microsoft.Extensions.DependencyInjection;
14+
using Microsoft.Extensions.DependencyInjection.Extensions;
1315

1416
namespace Elsa.Resilience.Features;
1517

@@ -69,9 +71,12 @@ public override void Configure()
6971
public override void Apply()
7072
{
7173
Services.AddOptions<ResilienceOptions>();
74+
Services.AddOptions<SimulateResponseOptions>();
75+
Services.TryAddSingleton(TimeProvider.System);
7276

7377
Services
7478
.AddSingleton<ResilienceStrategySerializer>()
79+
.AddSingleton<SimulateResponseSessionStore>()
7580
.AddSingleton<IActivityDescriptorModifier, ResilientActivityDescriptorModifier>()
7681
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
7782
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()
@@ -90,4 +95,4 @@ public override void Apply()
9095
.AddSingleton<ITransientExceptionStrategy, DefaultTransientExceptionStrategy>()
9196
.AddSingleton<ITransientExceptionDetector, TransientExceptionDetector>();
9297
}
93-
}
98+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Elsa.Resilience.Options;
2+
3+
public class SimulateResponseOptions
4+
{
5+
public int SessionCapacity { get; set; } = 1_000;
6+
public TimeSpan SessionSlidingExpiration { get; set; } = TimeSpan.FromMinutes(15);
7+
public int MaxSessionIdLength { get; set; } = 128;
8+
public int MaxCodesQueryLength { get; set; } = 1_024;
9+
public int MaxCodes { get; set; } = 32;
10+
}

src/modules/Elsa.Resilience/ShellFeatures/ResilienceFeature.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using CShells.Features;
33
using Elsa.Expressions.Options;
44
using Elsa.Extensions;
5+
using Elsa.Resilience.Endpoints.SimulateResponse;
56
using Elsa.Resilience.Entities;
67
using Elsa.Resilience.Modifiers;
78
using Elsa.Resilience.Options;
@@ -10,6 +11,7 @@
1011
using Elsa.Resilience.StrategySources;
1112
using Elsa.Workflows;
1213
using Microsoft.Extensions.DependencyInjection;
14+
using Microsoft.Extensions.DependencyInjection.Extensions;
1315

1416
namespace Elsa.Resilience.ShellFeatures;
1517

@@ -26,9 +28,12 @@ public void ConfigureServices(IServiceCollection services)
2628
});
2729

2830
services.AddOptions<ResilienceOptions>();
31+
services.AddOptions<SimulateResponseOptions>();
32+
services.TryAddSingleton(TimeProvider.System);
2933

3034
services
3135
.AddSingleton<ResilienceStrategySerializer>()
36+
.AddSingleton<SimulateResponseSessionStore>()
3237
.AddSingleton<IActivityDescriptorModifier, ResilientActivityDescriptorModifier>()
3338
.AddScoped<IResilienceStrategyCatalog, ResilienceStrategyCatalog>()
3439
.AddScoped<IResilienceStrategyConfigEvaluator, ResilienceStrategyConfigEvaluator>()

0 commit comments

Comments
 (0)