Merge pull request #7573 from elsa-workflows/codex/workflow-secret-references-s1

[codex] Protect sensitive workflow input descriptors and state
This commit is contained in:
Sipke Schoorstra 2026-06-01 20:14:06 +02:00 committed by GitHub
commit 0113f858dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 125 additions and 6 deletions

View file

@ -43,6 +43,11 @@ public class InputDescriptor : PropertyDescriptor
/// </summary>
public bool? IsReadOnly { get; set; }
/// <summary>
/// True if the input can contain secrets and should be treated as sensitive.
/// </summary>
public bool IsSensitive { get; set; }
/// <summary>
/// The storage driver type to use for persistence.
/// If no driver is specified, the referenced memory block will remain in memory for as long as the expression execution context exists.
@ -53,4 +58,4 @@ public class InputDescriptor : PropertyDescriptor
/// A dictionary of UI specifications to be used by the UI.
/// </summary>
public IDictionary<string, object>? UISpecifications { get; set; }
}
}

View file

@ -143,6 +143,12 @@ public static partial class ActivityExecutionContextExtensions
private static Task StoreInputValueAsync(ActivityExecutionContext context, InputDescriptor inputDescriptor, object value)
{
if (inputDescriptor.IsSensitive)
{
context.ActivityState.Remove(inputDescriptor.Name);
return Task.CompletedTask;
}
// Store the serialized input value in the activity state.
// Serializing the value ensures we store a copy of the value and not a reference to the input, which may change over time.
if (inputDescriptor.IsSerializable != false)
@ -157,4 +163,4 @@ public static partial class ActivityExecutionContextExtensions
return Task.CompletedTask;
}
}
}

View file

@ -164,7 +164,10 @@ public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolve
null,
propertyInfo,
uiSpecification
);
)
{
IsSensitive = inputAttribute?.CanContainSecrets ?? false
};
}
/// <inheritdoc />

View file

@ -146,7 +146,8 @@ public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) :
Order = inputAttribute?.Order ?? 0,
IsBrowsable = inputAttribute?.IsBrowsable ?? true,
AutoEvaluate = inputAttribute?.AutoEvaluate ?? true,
IsSerializable = inputAttribute?.IsSerializable ?? true
IsSerializable = inputAttribute?.IsSerializable ?? true,
IsSensitive = inputAttribute?.CanContainSecrets ?? false
};
}
@ -176,7 +177,8 @@ public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) :
Order = inputAttribute?.Order ?? 0,
IsBrowsable = inputAttribute?.IsBrowsable ?? true,
AutoEvaluate = inputAttribute?.AutoEvaluate ?? true,
IsSerializable = inputAttribute?.IsSerializable ?? true
IsSerializable = inputAttribute?.IsSerializable ?? true,
IsSensitive = inputAttribute?.CanContainSecrets ?? false
};
}
@ -243,4 +245,4 @@ public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) :
return true;
}
}
}

View file

@ -0,0 +1,57 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
using Xunit;
namespace Elsa.Workflows.Core.UnitTests.Extensions.ActivityExecutionContextExtensions;
public class InputEvaluationTests
{
private readonly ActivityWithSensitiveInputs _activity = new();
private readonly ActivityTestFixture _fixture;
public InputEvaluationTests()
{
_fixture = new(_activity);
}
[Fact]
public async Task EvaluateInputPropertiesAsync_WhenInputIsSensitive_RemovesItFromActivityState()
{
_fixture.ConfigureContext(context => context.ActivityState[nameof(ActivityWithSensitiveInputs.SensitiveText)] = "stale-secret");
var context = await ActivateAsync();
Assert.False(context.ActivityState.ContainsKey(nameof(ActivityWithSensitiveInputs.SensitiveText)));
Assert.Equal("secret", _activity.CapturedSensitiveText);
}
[Fact]
public async Task EvaluateInputPropertiesAsync_WhenInputIsNotSensitive_StoresItInActivityState()
{
var context = await ActivateAsync();
Assert.Equal("public", context.ActivityState[nameof(ActivityWithSensitiveInputs.PublicText)]);
Assert.Equal("public", _activity.CapturedPublicText);
}
private Task<ActivityExecutionContext> ActivateAsync() => _fixture.ExecuteAsync();
private sealed class ActivityWithSensitiveInputs : CodeActivity
{
[Input(CanContainSecrets = true)]
public Input<string?> SensitiveText { get; set; } = new("secret");
[Input]
public Input<string?> PublicText { get; set; } = new("public");
public string? CapturedSensitiveText { get; private set; }
public string? CapturedPublicText { get; private set; }
protected override void Execute(ActivityExecutionContext context)
{
CapturedSensitiveText = context.Get(SensitiveText);
CapturedPublicText = context.Get(PublicText);
}
}
}

View file

@ -0,0 +1,46 @@
using System.Reflection;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
using NSubstitute;
using Xunit;
namespace Elsa.Workflows.Core.UnitTests.Services;
public class ActivityDescriberTests
{
private readonly IActivityDescriber _describer;
public ActivityDescriberTests()
{
var defaultValueResolver = Substitute.For<IPropertyDefaultValueResolver>();
var propertyUIHandlerResolver = Substitute.For<IPropertyUIHandlerResolver>();
defaultValueResolver.GetDefaultValue(Arg.Any<PropertyInfo>()).Returns((object?)null);
propertyUIHandlerResolver
.GetUIPropertiesAsync(Arg.Any<PropertyInfo>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
.Returns(_ => new ValueTask<IDictionary<string, object>>(new Dictionary<string, object>()));
_describer = new ActivityDescriber(defaultValueResolver, propertyUIHandlerResolver);
}
[Fact]
public async Task DescribeActivityAsync_MapsCanContainSecretsToInputDescriptorSensitivity()
{
var descriptor = await _describer.DescribeActivityAsync(typeof(ActivityWithSensitiveInputs));
var sensitiveInput = descriptor.Inputs.Single(x => x.Name == nameof(ActivityWithSensitiveInputs.SensitiveText));
var publicInput = descriptor.Inputs.Single(x => x.Name == nameof(ActivityWithSensitiveInputs.PublicText));
Assert.True(sensitiveInput.IsSensitive);
Assert.False(publicInput.IsSensitive);
}
private sealed class ActivityWithSensitiveInputs : CodeActivity
{
[Input(CanContainSecrets = true)]
public Input<string?> SensitiveText { get; set; } = new("secret");
[Input]
public Input<string?> PublicText { get; set; } = new("public");
}
}