elsa-core/src/modules/Elsa.Http/Activities/HttpEndpoint.cs
Sipke Schoorstra 952cbb4edc
Add strict type checking for variable parsing (#6536)
* Add strict type checking for variable parsing

Introduced a `StrictMode` flag for variables, enforcing stricter type validation during parsing and conversions. Updated related methods and tests to reflect the stricter parsing behavior, ensuring consistent type compatibility and error handling.

* Refactor StrictMode handling and improve ObjectConverter logic

Moved StrictMode flag from Variable to ObjectConverter for better cohesion and updated related references. Additionally, removed unused parameters and simplified ObjectConverterOptions to streamline configuration and maintain consistency across the codebase.

* Refactor type conversion logic and update logging defaults

Improve type conversion reliability by introducing `TryConvertValue` and refining exception handling. Update JSON scenarios to use "Inherit" as the default for log persistence modes, ensuring consistency across configurations.

* Refactor exception handling in ObjectConverter.

Introduce a helper method `ReturnOrThrow` to streamline and centralize exception handling logic. This change also adds support for non-strict mode, allowing value fallback instead of throwing exceptions when enabled.

* Add numeric type checks and improve type conversion handling

Introduce `IsNumericType` extension method to identify numeric types. Enhance type conversion logic in `ObjectConverter` to handle numeric, boolean, and string types more accurately. Update variable deserialization workflows to handle conversion failures gracefully.

* Set a default comment for ObjectConverter.StrictMode assignment

Added a comment clarifying that StrictMode is set to its default value. This improves code readability and helps maintainers understand the intent.

* Fix formatting inconsistencies and improve code clarity

Removed redundant whitespace and adjusted formatting to align with coding standards. These changes enhance the readability and maintainability of the code without altering functionality.

* Fix typo in Program.cs variable comment

Corrected a minor issue in the comment for `identityTokenSection` to remove the unnecessary "Modify" text. This change improves code readability and removes potential confusion for developers.

* Simplify imports in ModifyVariableHandler.cs

Removed unused `Microsoft.Extensions.Options` and `Elsa.Workflows.Options` imports to clean up dependencies and improve maintainability. This change reduces clutter without affecting the existing functionality.
2025-03-31 18:11:14 +02:00

513 lines
20 KiB
C#

using System.Runtime.CompilerServices;
using System.Text.Json;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Http.Bookmarks;
using Elsa.Http.UIHints;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.UIHints;
using Elsa.Workflows.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Http;
/// <summary>
/// Wait for an inbound HTTP request that matches the specified path and methods.
/// </summary>
[Activity("Elsa", "HTTP", "Wait for an inbound HTTP request that matches the specified path and methods.", DisplayName = "HTTP Endpoint")]
[Output(IsSerializable = false)]
public class HttpEndpoint : Trigger<HttpRequest>
{
internal const string HttpContextInputKey = "HttpContext";
internal const string PathInputKey = "Path";
/// <inheritdoc />
public HttpEndpoint([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
}
/// <summary>
/// The path to associate with the workflow.
/// </summary>
[Input(
Description = "The path to associate with the workflow.",
UIHint = InputUIHints.SingleLine,
UIHandler = typeof(HttpEndpointPathUIHandler)
)]
public Input<string> Path { get; set; } = null!;
/// <summary>
/// The HTTP methods to accept.
/// </summary>
[Input(
Description = "The HTTP methods to accept.",
Options = new[] { "GET", "POST", "PUT", "HEAD", "DELETE" },
UIHint = InputUIHints.CheckList)]
public Input<ICollection<string>> SupportedMethods { get; set; } = new(ObjectLiteral.From(new[] { HttpMethods.Get }));
/// <summary>
/// Allow authenticated requests only.
/// </summary>
[Input(Description = "Allow authenticated requests only.", Category = "Security")]
public Input<bool> Authorize { get; set; } = new(false);
/// <summary>
/// Provide a policy to evaluate. If the policy fails, the request is forbidden.
/// </summary>
[Input(Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.", Category = "Security")]
public Input<string?> Policy { get; set; } = new(default(string?));
/// <summary>
/// The maximum time allowed to process the request.
/// </summary>
[Input(Description = "The maximum time allowed to process the request.", Category = "Upload")]
public Input<TimeSpan?> RequestTimeout { get; set; } = null!;
/// <summary>
/// The maximum request size allowed in bytes.
/// </summary>
[Input(Description = "The maximum request size allowed in bytes.", Category = "Upload")]
public Input<long?> RequestSizeLimit { get; set; } = null!;
/// <summary>
/// The maximum request size allowed in bytes.
/// </summary>
[Input(Description = "The maximum file size allowed in bytes for an individual file.", Category = "Upload")]
public Input<long?> FileSizeLimit { get; set; } = null!;
/// <summary>
/// The allowed file extensions,
/// </summary>
[Input(Description = "Only file extensions in this list are allowed. Leave empty to allow all extensions", Category = "Upload", UIHint = InputUIHints.MultiText)]
public Input<ICollection<string>> AllowedFileExtensions { get; set; } = null!;
/// <summary>
/// The allowed file extensions,
/// </summary>
[Input(Description = "File extensions in this list are forbidden. Leave empty to not block any extension.", Category = "Upload", UIHint = InputUIHints.MultiText)]
public Input<ICollection<string>> BlockedFileExtensions { get; set; } = null!;
/// <summary>
/// The allowed file extensions,
/// </summary>
[Input(Description = "Only MIME types in this list are allowed. Leave empty to allow all types", Category = "Upload", UIHint = InputUIHints.MultiText)]
public Input<ICollection<string>> AllowedMimeTypes { get; set; } = null!;
/// <summary>
/// A value indicating whether to expose the "Request too large" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"Request too large\" outcome.", Category = "Outcomes")]
public bool ExposeRequestTooLargeOutcome { get; set; }
/// <summary>
/// A value indicating whether to expose the "File too large" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"File too large\" outcome.", Category = "Outcomes")]
public bool ExposeFileTooLargeOutcome { get; set; }
/// <summary>
/// A value indicating whether to expose the "Invalid file extension" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"Invalid file extension\" outcome.", Category = "Outcomes")]
public bool ExposeInvalidFileExtensionOutcome { get; set; }
/// <summary>
/// A value indicating whether to expose the "Invalid file MIME type" outcome.
/// </summary>
[Input(Description = "A value indicating whether to expose the \"Invalid file MIME type\" outcome.", Category = "Outcomes")]
public bool ExposeInvalidFileMimeTypeOutcome { get; set; }
/// <summary>
/// The parsed request content, if any.
/// </summary>
[Output(Description = "The parsed request content, if any.")]
public Output<object?> ParsedContent { get; set; } = null!;
/// <summary>
/// The uploaded files, if any.
/// </summary>
[Output(Description = "The uploaded files, if any.", IsSerializable = false)]
public Output<IFormFile[]> Files { get; set; } = null!;
/// <summary>
/// The parsed route data, if any.
/// </summary>
[Output(Description = "The parsed route data, if any.")]
public Output<IDictionary<string, object>> RouteData { get; set; } = null!;
/// <summary>
/// The querystring data, if any.
/// </summary>
[Output(Description = "The querystring data, if any.")]
public Output<IDictionary<string, object>> QueryStringData { get; set; } = null!;
/// <summary>
/// The headers, if any.
/// </summary>
[Output(Description = "The headers, if any.")]
public Output<IDictionary<string, object>> Headers { get; set; } = null!;
/// <inheritdoc />
protected override IEnumerable<object> GetTriggerPayloads(TriggerIndexingContext context) => GetBookmarkPayloads(context.ExpressionExecutionContext);
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var path = Path.Get(context);
if (path.Contains("//"))
throw new RoutePatternException(path, "Path cannot contain double slashes (//)");
if (!context.IsTriggerOfWorkflow())
{
context.CreateBookmarks(GetBookmarkPayloads(context.ExpressionExecutionContext), includeActivityInstanceId: false, callback: OnResumeAsync);
return;
}
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
// We're executing in a non-HTTP context (e.g. in a virtual actor).
// Create a bookmark to allow the invoker to export the state and resume execution from there.
context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary);
return;
}
await HandleRequestAsync(context, httpContext);
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
var httpContextAccessor = context.GetRequiredService<IHttpContextAccessor>();
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
// We're executing in a non-HTTP context (e.g. in a virtual actor).
// Create a bookmark to allow the invoker to export the state and resume execution from there.
context.CreateBookmark(OnResumeAsync, BookmarkMetadata.HttpCrossBoundary);
return;
}
await HandleRequestAsync(context, httpContext);
}
private async Task HandleRequestAsync(ActivityExecutionContext context, HttpContext httpContext)
{
// Provide the received HTTP request as output.
var request = httpContext.Request;
context.Set(Result, request);
// Read route data, if any.
var path = context.GetWorkflowInput<PathString>(PathInputKey);
var routeData = GetRouteData(httpContext, path);
var routeDictionary = routeData.Values.ToDictionary(route => route.Key, route => route.Value!);
var queryStringDictionary = httpContext.Request.Query.ToObjectDictionary();
var headersDictionary = httpContext.Request.Headers.ToObjectDictionary();
context.Set(RouteData, routeDictionary);
context.Set(QueryStringData, queryStringDictionary);
context.Set(Headers, headersDictionary);
// Validate request size.
if (!ValidateRequestSize(context, httpContext))
{
await HandleRequestTooLargeAsync(context, httpContext);
return;
}
// Handle Form Fields
if (request.HasFormContentType)
{
var formFields = request.Form.ToObjectDictionary();
ParsedContent.Set(context, formFields);
// Read files, if any.
var files = ReadFilesAsync(context, request);
if (files.Any())
{
if (!ValidateFileSizes(context, httpContext, files))
{
await HandleFileSizeTooLargeAsync(context, httpContext);
return;
}
if (!ValidateFileExtensionWhitelist(context, httpContext, files))
{
await HandleInvalidFileExtensionWhitelistAsync(context, httpContext);
return;
}
if (!ValidateFileExtensionBlacklist(context, httpContext, files))
{
await HandleInvalidFileExtensionBlacklistAsync(context, httpContext);
return;
}
if (!ValidateFileMimeTypes(context, httpContext, files))
{
await HandleInvalidFileMimeTypesAsync(context, httpContext);
return;
}
Files.Set(context, files.ToArray());
}
}
else
{
// Parse Non-Form content.
try
{
var content = await ParseContentAsync(context, request);
ParsedContent.Set(context, content);
}
catch (JsonException e)
{
await HandleInvalidJsonPayloadAsync(context, httpContext, e);
throw;
}
}
// Complete.
await context.CompleteActivityAsync();
}
private IFormFileCollection ReadFilesAsync(ActivityExecutionContext context, HttpRequest request)
{
return request.HasFormContentType ? request.Form.Files : new FormFileCollection();
}
private bool ValidateRequestSize(ActivityExecutionContext context, HttpContext httpContext)
{
var requestSizeLimit = RequestSizeLimit.GetOrDefault(context);
if (!requestSizeLimit.HasValue)
return true;
var requestSize = httpContext.Request.ContentLength ?? 0;
return requestSize <= requestSizeLimit;
}
private async Task HandleRequestTooLargeAsync(ActivityExecutionContext context, HttpContext httpContext)
{
var exposeRequestTooLargeOutcome = ExposeRequestTooLargeOutcome;
if (exposeRequestTooLargeOutcome)
{
await context.CompleteActivityWithOutcomesAsync("Request too large");
}
else
{
var response = httpContext.Response;
response.StatusCode = StatusCodes.Status413PayloadTooLarge;
await response.WriteAsJsonAsync(new
{
Message = $"The maximum request size allowed is {RequestSizeLimit.Get(context)} bytes."
});
await response.Body.FlushAsync();
}
}
private bool ValidateFileSizes(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files)
{
var fileSizeLimit = FileSizeLimit.GetOrDefault(context);
if (!fileSizeLimit.HasValue)
return true;
if (!files.Any(file => file.Length > fileSizeLimit.Value))
return true;
return false;
}
private async Task HandleFileSizeTooLargeAsync(ActivityExecutionContext context, HttpContext httpContext)
{
var exposeFileTooLargeOutcome = ExposeFileTooLargeOutcome;
if (exposeFileTooLargeOutcome)
{
await context.CompleteActivityWithOutcomesAsync("File too large");
}
else
{
var response = httpContext.Response;
response.StatusCode = StatusCodes.Status413PayloadTooLarge;
await response.WriteAsJsonAsync(new
{
Message = $"The maximum file size allowed is {FileSizeLimit.Get(context)} bytes."
});
await response.Body.FlushAsync();
}
}
private bool ValidateFileExtensionWhitelist(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files)
{
var allowedFileExtensions = AllowedFileExtensions.GetOrDefault(context);
if (allowedFileExtensions == null || !allowedFileExtensions.Any())
return true;
if (files.All(file => allowedFileExtensions.Contains(System.IO.Path.GetExtension(file.FileName), StringComparer.OrdinalIgnoreCase)))
return true;
return false;
}
private async Task HandleInvalidFileExtensionWhitelistAsync(ActivityExecutionContext context, HttpContext httpContext)
{
if (ExposeInvalidFileExtensionOutcome)
{
await context.CompleteActivityWithOutcomesAsync("Invalid file extension");
return;
}
var response = httpContext.Response;
var allowedFileExtensions = AllowedFileExtensions.GetOrDefault(context)!;
response.StatusCode = StatusCodes.Status415UnsupportedMediaType;
await response.WriteAsJsonAsync(new
{
Message = $"Only the following file extensions are allowed: {string.Join(", ", allowedFileExtensions)}"
});
await response.Body.FlushAsync();
}
private bool ValidateFileExtensionBlacklist(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files)
{
var blockedFileExtensions = BlockedFileExtensions.GetOrDefault(context);
if (blockedFileExtensions == null || !blockedFileExtensions.Any())
return true;
if (!files.Any(file => blockedFileExtensions.Contains(System.IO.Path.GetExtension(file.FileName), StringComparer.OrdinalIgnoreCase)))
return true;
return false;
}
private async Task HandleInvalidFileExtensionBlacklistAsync(ActivityExecutionContext context, HttpContext httpContext)
{
if (ExposeInvalidFileExtensionOutcome)
{
await context.CompleteActivityWithOutcomesAsync("Invalid file extension");
return;
}
var blockedFileExtensions = BlockedFileExtensions.GetOrDefault(context)!;
var response = httpContext.Response;
response.StatusCode = StatusCodes.Status415UnsupportedMediaType;
await response.WriteAsJsonAsync(new
{
Message = $"The following file extensions are not allowed: {string.Join(", ", blockedFileExtensions)}"
});
await response.Body.FlushAsync();
}
private bool ValidateFileMimeTypes(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files)
{
var allowedMimeTypes = AllowedMimeTypes.GetOrDefault(context);
if (allowedMimeTypes == null || !allowedMimeTypes.Any())
return true;
if (files.All(file => allowedMimeTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase)))
return true;
return false;
}
private async Task HandleInvalidFileMimeTypesAsync(ActivityExecutionContext context, HttpContext httpContext)
{
if (ExposeInvalidFileMimeTypeOutcome)
{
await context.CompleteActivityWithOutcomesAsync("Invalid file MIME type");
return;
}
var allowedMimeTypes = AllowedMimeTypes.GetOrDefault(context)!;
var response = httpContext.Response;
response.StatusCode = StatusCodes.Status415UnsupportedMediaType;
await response.WriteAsJsonAsync(new
{
Message = $"Only the following MIME types are allowed: {string.Join(", ", allowedMimeTypes)}"
});
await response.Body.FlushAsync();
}
private async Task HandleInvalidJsonPayloadAsync(ActivityExecutionContext context, HttpContext httpContext, JsonException exception)
{
var response = httpContext.Response;
response.StatusCode = StatusCodes.Status400BadRequest;
await response.WriteAsJsonAsync(new
{
exception.Message,
exception.Path,
exception.LineNumber,
});
await response.Body.FlushAsync();
}
private async Task<object?> ParseContentAsync(ActivityExecutionContext context, HttpRequest httpRequest)
{
if (!HasContent(httpRequest))
return null;
var cancellationToken = context.CancellationToken;
var targetType = ParsedContent.GetTargetType(context);
var contentStream = httpRequest.Body;
var contentType = httpRequest.ContentType!;
var headers = httpRequest.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray());
return await context.ParseContentAsync(contentStream, contentType, targetType, headers, cancellationToken);
}
private static bool HasContent(HttpRequest httpRequest) => httpRequest.Headers.ContentLength > 0;
private IEnumerable<object> GetBookmarkPayloads(ExpressionExecutionContext context)
{
// Generate bookmark data for path and selected methods.
var normalizedRoute = context.Get(Path)!.NormalizeRoute();
var methods = SupportedMethods.GetOrDefault(context) ?? new List<string> { HttpMethods.Get };
var authorize = Authorize.GetOrDefault(context);
var policy = Policy.GetOrDefault(context);
var requestTimeout = RequestTimeout.GetOrDefault(context);
var requestSizeLimit = RequestSizeLimit.GetOrDefault(context);
return methods
.Select(x => new HttpEndpointBookmarkPayload(normalizedRoute, x.ToLowerInvariant(), authorize, policy, requestTimeout, requestSizeLimit))
.Cast<object>()
.ToArray();
}
private static RouteData GetRouteData(HttpContext httpContext, string path)
{
var routeData = httpContext.GetRouteData();
var routeTable = httpContext.RequestServices.GetRequiredService<IRouteTable>();
var routeMatcher = httpContext.RequestServices.GetRequiredService<IRouteMatcher>();
var matchingRouteQuery =
from route in routeTable
let routeValues = routeMatcher.Match(route.Route, path)
where routeValues != null
select new { route, routeValues };
var matchingRoute = matchingRouteQuery.FirstOrDefault();
if (matchingRoute == null)
return routeData;
foreach (var (key, value) in matchingRoute.routeValues!)
routeData.Values[key] = value;
return routeData;
}
}