From 66b9079d3e90f5ed02eed4c30b5a8d243545cb64 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 17 Aug 2026 02:21:19 +0300 Subject: [PATCH] Bound JavaScript expression execution (#7891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(javascript): bound JavaScript expression execution JavaScript expressions were evaluated with no execution constraints at all: no timeout, no statement limit, no memory limit, no recursion limit, and the ambient `CancellationToken` — already available at the call site and already passed into `IJavaScriptEvaluator.EvaluateAsync` — was never handed to Jint. An expression as simple as `while (true) {}` therefore occupied the calling thread for the lifetime of the process, and cancelling the workflow did not stop it. This adds: * `JintOptions.ExecutionTimeout` — wall-clock limit for a single expression, defaulting to 30 seconds. Deliberately generous so that existing expressions are unaffected; set to `null` to remove the limit. * `JintOptions.MaxStatements`, `JintOptions.MemoryLimit` and `JintOptions.MaxRecursionDepth` — opt-in resource limits, off by default. * The cancellation token is now passed to Jint, so cancelling a workflow aborts a script that is still running. The security assessment documents already described a JavaScript execution timeout as present; they now describe what is actually configurable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik * test(javascript): bound the constraint tests and cover the memory limit Three of the execution-constraint tests removed the execution timeout entirely and then ran `while (true) {}`, relying solely on the constraint under test to stop them. If that constraint regressed, the test did not fail — it ran until the CI job was killed, taking the rest of the suite with it. Each such test now registers a generous 30 second failsafe timeout instead of disabling the timeout. That is two orders of magnitude more than any of these constraints needs (the slowest aborts in ~270 ms), so it cannot become a flaky failure on a loaded machine, and `AssertAbortedByAsync` reports a failsafe trip as exactly that rather than as an unexplained exception type mismatch. The cancellation test also no longer races a wall-clock timer against engine construction: the script signals the token itself through a host function, so cancellation is guaranteed to land while the expression is running. The test went from a 250 ms wall-clock wait to 5 ms and has no timing dependency left. Adds the missing `MemoryLimit` test — the one configurable limit the suite did not exercise. Doubling a string crosses the limit within a couple of dozen statements, so it asserts `MemoryLimitExceededException` in ~40 ms and bounds how far past the limit the process can get before the check fires. Finally, the `ExpressionExecutionContext` is now built on the test host's `IServiceProvider` rather than a throwaway empty one, matching every other test in this project. An empty provider does not reflect real evaluation and can hide failures in notification handlers that resolve services. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik --------- Co-authored-by: Claude Opus 5 (1M context) --- .../elsa-core-architecture-patterns.md | 2 +- doc/security-assessment/elsa-core-profile.md | 2 +- .../Options/JintOptions.cs | 29 ++++ .../Services/JintJavaScriptEvaluator.cs | 20 +++ .../ExecutionConstraintTests.cs | 134 ++++++++++++++++++ 5 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 test/integration/Elsa.JavaScript.IntegrationTests/ExecutionConstraintTests.cs diff --git a/doc/security-assessment/elsa-core-architecture-patterns.md b/doc/security-assessment/elsa-core-architecture-patterns.md index 84c63b2ed..7fe1b83d2 100644 --- a/doc/security-assessment/elsa-core-architecture-patterns.md +++ b/doc/security-assessment/elsa-core-architecture-patterns.md @@ -107,7 +107,7 @@ | Deployment stamps | ❌ | No first-class multi-region stamp deployment tooling. | | Geode | ❌ | No geo-distribution support. | | Quarantine | ❌ | No quarantine/validation gate for inbound messages or data. | -| Timeout | ✅ | `Delay`, `StartAt`, `Timer`, and `Cron` activities implement time-based waits. `CancellationToken` propagation allows workflow-level and activity-level timeout cancellation. `JintOptions` exposes a JavaScript execution timeout. | +| Timeout | ✅ | `Delay`, `StartAt`, `Timer`, and `Cron` activities implement time-based waits. `CancellationToken` propagation allows workflow-level and activity-level timeout cancellation. `JintOptions.ExecutionTimeout` bounds how long a single JavaScript expression may run (30 seconds by default), and the ambient `CancellationToken` aborts a script that is still running; `JintOptions.MaxStatements`, `MemoryLimit` and `MaxRecursionDepth` add optional resource limits on top. | --- diff --git a/doc/security-assessment/elsa-core-profile.md b/doc/security-assessment/elsa-core-profile.md index 526412da4..1b633c575 100644 --- a/doc/security-assessment/elsa-core-profile.md +++ b/doc/security-assessment/elsa-core-profile.md @@ -177,7 +177,7 @@ Elsa provides a pluggable expression evaluation system via `IExpressionHandler` | ElsaScript DSL | `Elsa.Dsl.ElsaScript` | Custom regex-based parser + compiler | | Literal / Delegate | `Elsa.Expressions` | Native .NET | -The JavaScript engine (Jint) supports optional CLR access (`AllowClrAccess`) and optional `getConfig` access for reading `IConfiguration` values; both are disabled by default for security. Python requires a Python runtime path configured via `PYTHONNET_PYDLL` or application settings. +The JavaScript engine (Jint) supports optional CLR access (`AllowClrAccess`) and optional `getConfig` access for reading `IConfiguration` values; both are disabled by default for security. Script execution is bounded by `JintOptions.ExecutionTimeout` (30 seconds by default) and by the ambient `CancellationToken`; `MaxStatements`, `MemoryLimit` and `MaxRecursionDepth` are available as additional opt-in limits. Python requires a Python runtime path configured via `PYTHONNET_PYDLL` or application settings. --- diff --git a/src/modules/Elsa.Expressions.JavaScript/Options/JintOptions.cs b/src/modules/Elsa.Expressions.JavaScript/Options/JintOptions.cs index 51f5306b5..cd2dc9906 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Options/JintOptions.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Options/JintOptions.cs @@ -32,6 +32,35 @@ public class JintOptions /// public bool AllowConfigurationAccess { get; set; } + /// + /// The maximum wall-clock time a single JavaScript expression may run for. Set to null to remove the limit. + /// + /// + /// Without a limit, an expression such as while (true) {} occupies the calling thread indefinitely. + /// The default is deliberately generous so that legitimate expressions are unaffected; hosts that evaluate + /// user-defined workflows are encouraged to lower it. + /// + public TimeSpan? ExecutionTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// The maximum number of statements a single JavaScript expression may execute. Set to null (the default) to remove the limit. + /// + public int? MaxStatements { get; set; } + + /// + /// The maximum amount of memory, in bytes, a single JavaScript expression may allocate. Set to null (the default) to remove the limit. + /// + public long? MemoryLimit { get; set; } + + /// + /// The maximum recursion depth allowed within a single JavaScript expression. Set to null (the default) to remove the limit. + /// + /// + /// Unbounded recursion in a script exhausts the CLR stack, which cannot be recovered from. Hosts that evaluate + /// user-defined workflows are encouraged to set a limit. + /// + public int? MaxRecursionDepth { get; set; } + /// /// The timeout for script caching. /// diff --git a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs index 15c1818cb..ec6531f0b 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -63,6 +63,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification ConfigureClrAccess(engineOptions); ConfigureObjectWrapper(engineOptions); ConfigureObjectConverters(engineOptions); + ConfigureExecutionConstraints(engineOptions, cancellationToken); await mediator.SendAsync(new CreatingJavaScriptEngine(engineOptions, context), cancellationToken); _jintOptions.ConfigureEngineOptionsCallback(engineOptions, context); @@ -96,6 +97,25 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification }); } + private void ConfigureExecutionConstraints(Jint.Options options, CancellationToken cancellationToken) + { + // An expression that never returns would otherwise occupy the calling thread forever. + if (_jintOptions.ExecutionTimeout is { } executionTimeout) + options.TimeoutInterval(executionTimeout); + + if (_jintOptions.MaxStatements is { } maxStatements) + options.MaxStatements(maxStatements); + + if (_jintOptions.MemoryLimit is { } memoryLimit) + options.LimitMemory(memoryLimit); + + if (_jintOptions.MaxRecursionDepth is { } maxRecursionDepth) + options.LimitRecursion(maxRecursionDepth); + + // Cancelling the workflow should also abort a script that is still running. + options.CancellationToken(cancellationToken); + } + private void ConfigureObjectConverters(Jint.Options options) { options.Interop.ObjectConverters.AddRange(ObjectConverters); diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/ExecutionConstraintTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/ExecutionConstraintTests.cs new file mode 100644 index 000000000..8267333c3 --- /dev/null +++ b/test/integration/Elsa.JavaScript.IntegrationTests/ExecutionConstraintTests.cs @@ -0,0 +1,134 @@ +using Elsa.Expressions.JavaScript.Contracts; +using Elsa.Expressions.JavaScript.Options; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Jint; +using Jint.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.JavaScript.IntegrationTests; + +/// +/// Verifies that a runaway JavaScript expression cannot occupy the calling thread indefinitely. +/// +public class ExecutionConstraintTests(ITestOutputHelper testOutputHelper) +{ + private const string InfiniteLoop = "while (true) {}"; + + /// + /// Tests whose subject is a constraint other than the timeout still register a timeout, so that a regression + /// in the constraint under test fails that one test instead of leaving an infinite loop running until the CI + /// job is killed. It is orders of magnitude longer than those constraints need — each of them aborts within + /// milliseconds — so it cannot itself become a source of flaky failures on a loaded machine, and + /// reports a trip as a failsafe trip rather than as a + /// confusing exception type mismatch. + /// + private static readonly TimeSpan FailsafeTimeout = TimeSpan.FromSeconds(30); + + [Fact(DisplayName = "An expression that never returns is aborted by the execution timeout")] + public async Task ExecutionTimeoutAbortsRunawayExpression() + { + // No failsafe needed: the timeout is the subject here, so the test is bounded by the thing it asserts. + var services = BuildServices(options => options.ExecutionTimeout = TimeSpan.FromMilliseconds(250)); + + await Assert.ThrowsAsync(() => EvaluateAsync(services, InfiniteLoop)); + } + + [Fact(DisplayName = "An expression that never returns is aborted when the cancellation token is signalled")] + public async Task CancellationAbortsRunawayExpression() + { + var services = BuildServices(options => options.ExecutionTimeout = FailsafeTimeout); + using var cancellationTokenSource = new CancellationTokenSource(); + + // The script signals the token itself, so cancellation is guaranteed to land after the engine has been + // built and while the expression is running. A wall-clock timer would instead race engine construction + // and could fault the setup rather than the script on a loaded machine. + await AssertAbortedByAsync(() => EvaluateAsync( + services, + "cancel(); " + InfiniteLoop, + engine => engine.SetValue("cancel", (Action)cancellationTokenSource.Cancel), + cancellationTokenSource.Token)); + } + + [Fact(DisplayName = "An expression that exceeds the statement limit is aborted")] + public async Task StatementLimitAbortsRunawayExpression() + { + var services = BuildServices(options => + { + options.ExecutionTimeout = FailsafeTimeout; + options.MaxStatements = 100; + }); + + await AssertAbortedByAsync(() => EvaluateAsync(services, InfiniteLoop)); + } + + [Fact(DisplayName = "An expression that allocates without bound is aborted by the memory limit")] + public async Task MemoryLimitAbortsAllocationHeavyExpression() + { + var services = BuildServices(options => + { + options.ExecutionTimeout = FailsafeTimeout; + options.MemoryLimit = 4 * 1024 * 1024; + }); + + // The limit is checked between statements, so the script has to allocate in visible steps. Doubling the + // string crosses any limit within a couple of dozen iterations, which keeps the test fast and bounds how + // far past the limit the process can get before the check fires. + await AssertAbortedByAsync(() => EvaluateAsync(services, "var s = 'x'; while (true) { s += s; }")); + } + + [Fact(DisplayName = "An expression that recurses without bound is aborted")] + public async Task RecursionLimitAbortsRunawayExpression() + { + var services = BuildServices(options => + { + options.ExecutionTimeout = FailsafeTimeout; + options.MaxRecursionDepth = 32; + }); + + await AssertAbortedByAsync(() => EvaluateAsync(services, "function f() { return f(); } return f();")); + } + + [Fact(DisplayName = "A well-behaved expression is unaffected by the default constraints")] + public async Task DefaultConstraintsDoNotAffectNormalExpressions() + { + var services = BuildServices(_ => { }); + + Assert.Equal("3", await EvaluateAsync(services, "return '' + (1 + 2);")); + } + + private IServiceProvider BuildServices(Action configure) + { + return new TestApplicationBuilder(testOutputHelper) + .ConfigureElsa(elsa => elsa.UseJavaScript(configure)) + .Build(); + } + + private static async Task EvaluateAsync(IServiceProvider services, string script, Action? configureEngine = null, CancellationToken cancellationToken = default) + { + var evaluator = services.GetRequiredService(); + var context = new ExpressionExecutionContext(services, new()); + + return (string?)await evaluator.EvaluateAsync(script, typeof(string), context, configureEngine: configureEngine, cancellationToken: cancellationToken); + } + + /// + /// Asserts that the expression was aborted by , distinguishing that outcome + /// from a trip of the failsafe execution timeout that guards the test against running unbounded. + /// + private static async Task AssertAbortedByAsync(Func evaluate) where TException : Exception + { + var exception = await Record.ExceptionAsync(evaluate); + + if (exception is null) + Assert.Fail($"Expected the expression to be aborted by {typeof(TException).Name}, but it ran to completion."); + + if (exception is TimeoutException) + Assert.Fail($"The {FailsafeTimeout.TotalSeconds:0} second failsafe execution timeout fired before {typeof(TException).Name} was thrown: the constraint under test did not abort the expression."); + + Assert.IsType(exception); + } +}