Conversation
SummaryAdds assembly validation to TypeDiscriminatorConverter to prevent arbitrary type instantiation from untrusted JSON by restricting deserialization to types from the ModularPipelines assembly only. Critical Issues1. TypeDiscriminatorConverter appears to be unused/dead code The codebase currently uses
Questions:
2. Security fix may be incomplete While assembly restriction is better than nothing, the issue described Option 1: Registered Type Map as the recommended approach, but this PR implements Option 2: Assembly Restriction, which is less secure because:
Consider using an explicit allowlist instead: private static readonly HashSet<Type> AllowedTypes = new()
{
// Explicitly list allowed types here
};
if (!AllowedTypes.Contains(type))
{
throw new JsonException(...);
}3. Test implementation has issues src/ModularPipelines.UnitTests/Serialization/TypeDiscriminatorConverterSecurityTests.cs:
Since SuggestionsConsider deleting TypeDiscriminatorConverter entirely If it's not used and git grep -n "TypeDiscriminatorConverter" --and --not -e "namespace" -e "class TypeDiscriminatorConverter"If there are no hits, the class is unused. Verdict |
There was a problem hiding this comment.
Pull request overview
This pull request implements a security fix to prevent arbitrary type instantiation through JSON deserialization in the TypeDiscriminatorConverter<T>. The fix restricts deserialization to types from the ModularPipelines assembly only, preventing potential malicious JSON payloads from instantiating arbitrary types from any loaded assembly.
- Adds assembly validation to reject types from unauthorized assemblies
- Provides clear error messages for security violations
- Includes comprehensive security tests validating the new restrictions
- Adds a design document for future ModuleResult refactoring work (issue #1869)
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/ModularPipelines/Serialization/TypeDiscriminatorConverter.cs | Adds assembly validation check to restrict deserialization to ModularPipelines assembly types only, with clear security-focused error messages |
| test/ModularPipelines.UnitTests/Serialization/TypeDiscriminatorConverterSecurityTests.cs | Adds comprehensive security tests verifying unauthorized assemblies are rejected and authorized types are allowed, though with a duplicated test implementation |
| docs/plans/2026-01-07-module-result-discriminated-union.md | Adds design documentation for a future discriminated union refactoring (unrelated to this security fix) |
| # ModuleResult Discriminated Union Design | ||
|
|
||
| **Issue:** #1869 | ||
| **Date:** 2026-01-07 | ||
| **Status:** Approved | ||
|
|
||
| ## Problem | ||
|
|
||
| The current `ModuleResult<T>.Value` property throws exceptions when accessed on failed or skipped results: | ||
|
|
||
| ```csharp | ||
| var result = await myModule; | ||
| var value = result.Value; // Throws ModuleFailedException or ModuleSkippedException! | ||
| ``` | ||
|
|
||
| This leads to: | ||
| - Implicit exceptions and poor control flow | ||
| - No compile-time exhaustiveness checking | ||
| - Awkward try-catch patterns around property access | ||
|
|
||
| ## Solution | ||
|
|
||
| Replace with a discriminated union pattern using C# records: | ||
|
|
||
| ```csharp | ||
| public abstract record ModuleResult<T> : IModuleResult | ||
| { | ||
| // Metadata (available on all outcomes) | ||
| public required string ModuleName { get; init; } | ||
| public required TimeSpan ModuleDuration { get; init; } | ||
| public required DateTimeOffset ModuleStart { get; init; } | ||
| public required DateTimeOffset ModuleEnd { get; init; } | ||
| public required Status ModuleStatus { get; init; } | ||
|
|
||
| // Quick checks | ||
| public bool IsSuccess => this is Success; | ||
| public bool IsFailure => this is Failure; | ||
| public bool IsSkipped => this is Skipped; | ||
|
|
||
| // Safe accessors (no exceptions) | ||
| public T? ValueOrDefault => this is Success s ? s.Value : default; | ||
| public Exception? ExceptionOrDefault => this is Failure f ? f.Exception : null; | ||
| public SkipDecision? SkipDecisionOrDefault => this is Skipped sk ? sk.Decision : null; | ||
|
|
||
| // Computed for compatibility | ||
| public ModuleResultType ModuleResultType => this switch | ||
| { | ||
| Success => ModuleResultType.Success, | ||
| Failure => ModuleResultType.Failure, | ||
| Skipped => ModuleResultType.Skipped, | ||
| _ => throw new InvalidOperationException() | ||
| }; | ||
|
|
||
| // Discriminated variants | ||
| public sealed record Success(T Value) : ModuleResult<T>; | ||
| public sealed record Failure(Exception Exception) : ModuleResult<T>; | ||
| public sealed record Skipped(SkipDecision Decision) : ModuleResult<T>; | ||
|
|
||
| // Prevent external inheritance | ||
| private ModuleResult() { } | ||
| } | ||
| ``` | ||
|
|
||
| ## Usage Examples | ||
|
|
||
| ### Pattern Matching (Recommended) | ||
|
|
||
| ```csharp | ||
| var result = await buildModule; | ||
|
|
||
| switch (result) | ||
| { | ||
| case ModuleResult<string>.Success { Value: var value }: | ||
| Console.WriteLine($"Got: {value}"); | ||
| break; | ||
| case ModuleResult<string>.Failure { Exception: var ex }: | ||
| Console.WriteLine($"Failed: {ex.Message}"); | ||
| break; | ||
| case ModuleResult<string>.Skipped { Decision.Reason: var reason }: | ||
| Console.WriteLine($"Skipped: {reason}"); | ||
| break; | ||
| } | ||
| ``` | ||
|
|
||
| ### Match Helper | ||
|
|
||
| ```csharp | ||
| string message = result.Match( | ||
| onSuccess: value => $"Got: {value}", | ||
| onFailure: ex => $"Error: {ex.Message}", | ||
| onSkipped: skip => $"Skipped: {skip.Reason}" | ||
| ); | ||
| ``` | ||
|
|
||
| ### Quick Checks | ||
|
|
||
| ```csharp | ||
| if (result.IsSuccess) | ||
| { | ||
| DoSomething(result.ValueOrDefault!); | ||
| } | ||
| ``` | ||
|
|
||
| ## Helper Methods | ||
|
|
||
| ```csharp | ||
| // Match - transform to single type | ||
| public TResult Match<TResult>( | ||
| Func<T, TResult> onSuccess, | ||
| Func<Exception, TResult> onFailure, | ||
| Func<SkipDecision, TResult> onSkipped); | ||
|
|
||
| // Switch - side effects | ||
| public void Switch( | ||
| Action<T> onSuccess, | ||
| Action<Exception> onFailure, | ||
| Action<SkipDecision> onSkipped); | ||
| ``` | ||
|
|
||
| ## JSON Serialization | ||
|
|
||
| Uses .NET 7+ polymorphic serialization: | ||
|
|
||
| ```csharp | ||
| [JsonDerivedType(typeof(Success), "Success")] | ||
| [JsonDerivedType(typeof(Failure), "Failure")] | ||
| [JsonDerivedType(typeof(Skipped), "Skipped")] | ||
| public abstract record ModuleResult<T> : IModuleResult | ||
| ``` | ||
|
|
||
| Output: | ||
| ```json | ||
| { | ||
| "$type": "Success", | ||
| "Value": "build output", | ||
| "ModuleName": "BuildModule", | ||
| "ModuleDuration": "00:01:23" | ||
| } | ||
| ``` | ||
|
|
||
| ## Non-Generic Interface | ||
|
|
||
| For type-erased scenarios (registries, summaries): | ||
|
|
||
| ```csharp | ||
| public interface IModuleResult | ||
| { | ||
| string ModuleName { get; } | ||
| TimeSpan ModuleDuration { get; } | ||
| DateTimeOffset ModuleStart { get; } | ||
| DateTimeOffset ModuleEnd { get; } | ||
| Status ModuleStatus { get; } | ||
| ModuleResultType ModuleResultType { get; } | ||
|
|
||
| object? ValueOrDefault { get; } | ||
| Exception? ExceptionOrDefault { get; } | ||
| SkipDecision? SkipDecisionOrDefault { get; } | ||
| } | ||
| ``` | ||
|
|
||
| ## Factory Methods | ||
|
|
||
| Internal factory methods for engine use: | ||
|
|
||
| ```csharp | ||
| internal static Success CreateSuccess(T value, ModuleExecutionContext ctx); | ||
| internal static Failure CreateFailure(Exception exception, ModuleExecutionContext ctx); | ||
| internal static Skipped CreateSkipped(SkipDecision decision, ModuleExecutionContext ctx); | ||
| ``` | ||
|
|
||
| ## Breaking Changes | ||
|
|
||
| ### Removed | ||
| - `ModuleResult<T>.Value` property (the throwing one) | ||
| - `ModuleFailedException` class | ||
| - `ModuleSkippedException` class | ||
| - `SkippedModuleResult<T>` subclass | ||
| - `TimedOutModuleResult<T>` subclass | ||
|
|
||
| ### Preserved | ||
| - `ModuleResultType` property (now computed) | ||
| - `IModuleResult` interface | ||
| - All metadata property names | ||
| - JSON serialization support | ||
|
|
||
| ## Target | ||
|
|
||
| .NET 10 only - no backward compatibility shims needed. | ||
|
|
||
| ## Files to Modify | ||
|
|
||
| 1. `src/ModularPipelines/Models/ModuleResult.cs` - Main implementation | ||
| 2. `src/ModularPipelines/Models/IModuleResult.cs` - Update interface | ||
| 3. `src/ModularPipelines/Engine/Execution/ModuleResultFactory.cs` - Update factory | ||
| 4. `src/ModularPipelines/Exceptions/` - Remove ModuleFailedException, ModuleSkippedException | ||
| 5. `src/ModularPipelines/Models/SkippedModuleResult.cs` - Delete | ||
| 6. `src/ModularPipelines/Models/TimedOutModuleResult.cs` - Delete | ||
| 7. All files accessing `.Value` - Update to pattern matching |
There was a problem hiding this comment.
This design document appears to be unrelated to the security fix described in the PR title and description. The PR is about fixing issue #1910 (security vulnerability in TypeDiscriminatorConverter), but this document discusses issue #1869 (ModuleResult discriminated union refactoring).
If this document was added intentionally as part of planning for future work, it should be in a separate PR to keep the security fix focused and easier to review. If it was added accidentally, it should be removed from this PR.
| /// A testable version of the TypeDiscriminatorConverter that exposes the Read method. | ||
| /// This is needed because the original converter is internal. | ||
| /// </summary> | ||
| private class TestableTypeDiscriminatorConverter : JsonConverter<ModuleResult> | ||
| { | ||
| private static readonly Assembly AllowedAssembly = typeof(ModuleResult).Assembly; | ||
|
|
||
| public override ModuleResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||
| { | ||
| if (reader.TokenType != JsonTokenType.StartObject) | ||
| { | ||
| throw new JsonException( | ||
| $"Expected JSON object start token but found '{reader.TokenType}' when deserializing type '{typeToConvert.FullName}'."); | ||
| } | ||
|
|
||
| using var jsonDocument = JsonDocument.ParseValue(ref reader); | ||
|
|
||
| if (!jsonDocument.RootElement.TryGetProperty(nameof(ITypeDiscriminator.TypeDiscriminator), out var typeProperty)) | ||
| { | ||
| throw new JsonException( | ||
| $"Missing required property '{nameof(ITypeDiscriminator.TypeDiscriminator)}' when deserializing type '{typeToConvert.FullName}'. " + | ||
| $"Ensure the JSON contains a type discriminator property."); | ||
| } | ||
|
|
||
| var typeDiscriminatorValue = typeProperty.GetString(); | ||
| var type = Type.GetType(typeDiscriminatorValue!); | ||
|
|
||
| if (type == null) | ||
| { | ||
| throw new JsonException( | ||
| $"Could not resolve type from discriminator value '{typeDiscriminatorValue}' when deserializing type '{typeToConvert.FullName}'. " + | ||
| $"Ensure the type exists and is properly qualified."); | ||
| } | ||
|
|
||
| // Security: Only allow types from the ModularPipelines assembly to prevent arbitrary type instantiation | ||
| if (type.Assembly != AllowedAssembly) | ||
| { | ||
| throw new JsonException( | ||
| $"Type '{typeDiscriminatorValue}' from unauthorized assembly '{type.Assembly.GetName().Name}'. " + | ||
| $"Only types from the ModularPipelines assembly are allowed for security reasons."); | ||
| } | ||
|
|
||
| // Note: We stop here for testing purposes - the actual deserialization is tested elsewhere | ||
| // Return null to indicate the security check passed | ||
| return null!; | ||
| } | ||
|
|
||
| public override void Write(Utf8JsonWriter writer, ModuleResult value, JsonSerializerOptions options) | ||
| { | ||
| throw new NotImplementedException("Write not needed for security tests"); |
There was a problem hiding this comment.
The TestableTypeDiscriminatorConverter duplicates most of the logic from the actual TypeDiscriminatorConverter, which creates a maintenance burden. If the actual implementation changes, this test version needs to be updated separately, and they could drift out of sync.
Consider testing the actual TypeDiscriminatorConverter through integration tests with JsonSerializer.Deserialize() instead of creating a duplicate implementation. This would ensure you're testing the real code path and avoid code duplication.
| /// A testable version of the TypeDiscriminatorConverter that exposes the Read method. | |
| /// This is needed because the original converter is internal. | |
| /// </summary> | |
| private class TestableTypeDiscriminatorConverter : JsonConverter<ModuleResult> | |
| { | |
| private static readonly Assembly AllowedAssembly = typeof(ModuleResult).Assembly; | |
| public override ModuleResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | |
| { | |
| if (reader.TokenType != JsonTokenType.StartObject) | |
| { | |
| throw new JsonException( | |
| $"Expected JSON object start token but found '{reader.TokenType}' when deserializing type '{typeToConvert.FullName}'."); | |
| } | |
| using var jsonDocument = JsonDocument.ParseValue(ref reader); | |
| if (!jsonDocument.RootElement.TryGetProperty(nameof(ITypeDiscriminator.TypeDiscriminator), out var typeProperty)) | |
| { | |
| throw new JsonException( | |
| $"Missing required property '{nameof(ITypeDiscriminator.TypeDiscriminator)}' when deserializing type '{typeToConvert.FullName}'. " + | |
| $"Ensure the JSON contains a type discriminator property."); | |
| } | |
| var typeDiscriminatorValue = typeProperty.GetString(); | |
| var type = Type.GetType(typeDiscriminatorValue!); | |
| if (type == null) | |
| { | |
| throw new JsonException( | |
| $"Could not resolve type from discriminator value '{typeDiscriminatorValue}' when deserializing type '{typeToConvert.FullName}'. " + | |
| $"Ensure the type exists and is properly qualified."); | |
| } | |
| // Security: Only allow types from the ModularPipelines assembly to prevent arbitrary type instantiation | |
| if (type.Assembly != AllowedAssembly) | |
| { | |
| throw new JsonException( | |
| $"Type '{typeDiscriminatorValue}' from unauthorized assembly '{type.Assembly.GetName().Name}'. " + | |
| $"Only types from the ModularPipelines assembly are allowed for security reasons."); | |
| } | |
| // Note: We stop here for testing purposes - the actual deserialization is tested elsewhere | |
| // Return null to indicate the security check passed | |
| return null!; | |
| } | |
| public override void Write(Utf8JsonWriter writer, ModuleResult value, JsonSerializerOptions options) | |
| { | |
| throw new NotImplementedException("Write not needed for security tests"); | |
| /// A testable wrapper around the real TypeDiscriminatorConverter that exposes its behavior for tests | |
| /// without duplicating the implementation logic. | |
| /// </summary> | |
| private class TestableTypeDiscriminatorConverter : JsonConverter<ModuleResult> | |
| { | |
| private readonly JsonConverter<ModuleResult> _innerConverter = new TypeDiscriminatorConverter(); | |
| public override ModuleResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | |
| { | |
| return _innerConverter.Read(ref reader, typeToConvert, options); | |
| } | |
| public override void Write(Utf8JsonWriter writer, ModuleResult value, JsonSerializerOptions options) | |
| { | |
| _innerConverter.Write(writer, value, options); |
| [Test] | ||
| public async Task AllowedModularPipelinesType_IsAccepted() | ||
| { | ||
| // Arrange - Create JSON with a type from the ModularPipelines assembly | ||
| var validJson = """ | ||
| { | ||
| "TypeDiscriminator": "ModularPipelines.Models.ModuleResult, ModularPipelines", | ||
| "ModuleName": "TestModule", | ||
| "ModuleDuration": "00:00:01", | ||
| "ModuleStart": "2024-01-01T00:00:00+00:00", | ||
| "ModuleEnd": "2024-01-01T00:00:01+00:00" | ||
| } | ||
| """; | ||
|
|
||
| // Act - The converter should not throw for allowed types | ||
| // Note: We can't fully deserialize without the recursive issue, but we can verify | ||
| // the security check passes by ensuring it reaches the deserialization step | ||
| var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(validJson)); | ||
| var converter = new TestableTypeDiscriminatorConverter(); | ||
| var options = new JsonSerializerOptions(); | ||
| reader.Read(); // Move to first token | ||
|
|
||
| // This should NOT throw the security exception - it will attempt to deserialize | ||
| // which may have its own issues, but the security check should pass | ||
| JsonException? securityException = null; | ||
| try | ||
| { | ||
| converter.Read(ref reader, typeof(ModuleResult), options); | ||
| } | ||
| catch (JsonException ex) when (ex.Message.Contains("unauthorized assembly")) | ||
| { | ||
| securityException = ex; | ||
| } | ||
| catch | ||
| { | ||
| // Other exceptions are expected (e.g., deserialization issues) | ||
| // The key is that we don't get a security exception | ||
| } | ||
|
|
||
| await Assert.That(securityException).IsNull(); | ||
| } |
There was a problem hiding this comment.
The AllowedModularPipelinesType_IsAccepted test doesn't actually verify that deserialization succeeds. It returns null on line 206 and only checks that a security exception wasn't thrown. This doesn't confirm that valid ModularPipelines types can be successfully deserialized.
The test would be more valuable if it either completed the deserialization or was restructured to use the actual TypeDiscriminatorConverter through JsonSerializer to verify end-to-end functionality.
| // Security: Only allow types from the ModularPipelines assembly to prevent arbitrary type instantiation | ||
| if (type.Assembly != AllowedAssembly) | ||
| { | ||
| throw new JsonException( | ||
| $"Type '{typeDiscriminatorValue}' from unauthorized assembly '{type.Assembly.GetName().Name}'. " + | ||
| $"Only types from the ModularPipelines assembly are allowed for security reasons."); | ||
| } |
There was a problem hiding this comment.
The assembly check uses reference equality (!=) which is correct, but the implementation doesn't consider that derived types in the ModularPipelines assembly might legitimately inherit from types in other assemblies. The current check validates the instantiated type's assembly, not the base type hierarchy.
While this is likely the intended behavior for security purposes, consider adding a comment explaining that only types directly defined in the ModularPipelines assembly are allowed, and that inheritance from external types is explicitly restricted.
…ction Addresses review feedback on PR #1929: - Changed from assembly-level restriction to explicit type allowlist - Only ModuleResult and types deriving from it are now permitted - Added IsTypeAllowed() method for testability - Rewrote tests to use the real TypeDiscriminatorConverter via InternalsVisibleTo - Tests now directly verify IsTypeAllowed() and converter behavior - Removed duplicate test implementation that copied the converter logic This implements Option 1 (Registered Type Map) from issue #1910 which is more secure than Option 2 (Assembly Restriction). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Review Comments AddressedThank you for the thorough review! I've addressed all the feedback: 1. ✅ TypeDiscriminatorConverter IS usedThe converter is used by [JsonConverter(typeof(TypeDiscriminatorConverter<ModuleResult>))]
public class ModuleResult : IModuleResult, ITypeDiscriminator2. ✅ Implemented Option 1 (Explicit Allowlist) instead of Option 2 (Assembly Restriction)Changed from assembly-level restriction to an explicit type allowlist: private static readonly HashSet<Type> AllowedTypes = new()
{
typeof(ModuleResult),
};The
This is more secure because:
3. ✅ Fixed tests to use real implementation
All tests pass. |
SummaryAdds security validation to TypeDiscriminatorConverter to prevent arbitrary type instantiation via JSON deserialization. Critical IssuesBLOCKING: Security validation applied to wrong converter ⛔ The PR adds security hardening to Evidence from src/ModularPipelines/Models/ModuleResult.cs:30: [JsonConverter(typeof(ModuleResultJsonConverterFactory))]
public abstract record ModuleResult<T> : IModuleResultThe actual deserialization happens in
Impact:
Questions:
Recommendation:
SuggestionsNone - need clarification on the critical issue first. Verdict |
Response to Latest ReviewThe reviewer's concerns are based on incorrect information. Let me clarify: ✅ TypeDiscriminatorConverter IS the correct converterEvidence from current codebase: $ grep -n "JsonConverter" src/ModularPipelines/Models/ModuleResult.cs
75:[JsonConverter(typeof(TypeDiscriminatorConverter<ModuleResult>))]Line 75-76 of ModuleResult.cs: [JsonConverter(typeof(TypeDiscriminatorConverter<ModuleResult>))]
public class ModuleResult : IModuleResult, ITypeDiscriminator✅ ModuleResultJsonConverterFactory does NOT exist$ grep -rn "ModuleResultJsonConverter" src/ --include="*.cs"
(no results)There is no ✅ ITypeDiscriminator is implemented by ModuleResult$ grep -rn "ITypeDiscriminator" src/ModularPipelines/ --include="*.cs" | grep -v "interface"
src/ModularPipelines/Models/ModuleResult.cs:76:public class ModuleResult : IModuleResult, ITypeDiscriminatorSummaryThe security fix is applied to the correct converter:
The PR correctly addresses the security vulnerability described in issue #1910. |
…security risk The TypeDiscriminatorConverter allowed arbitrary type instantiation from JSON type discriminator values, which posed a security vulnerability. Since the ModuleResult refactoring (PR #1895), nothing implements ITypeDiscriminator anymore, making this code dead. Rather than adding security restrictions to unused code, this commit removes the dead code entirely: - Removes TypeDiscriminatorConverter<T> - Removes ITypeDiscriminator interface This eliminates the security vulnerability by removing the vulnerable code path. Closes #1910 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
a9bbe21 to
a4afeed
Compare
Updated ApproachAfter rebasing onto main, I discovered that PR #1895 (discriminated union pattern for ModuleResult) completely changed the serialization approach:
Rather than adding security restrictions to dead code, I've updated this PR to simply remove the vulnerable code entirely:
This is the safest approach - you can't exploit code that doesn't exist. |
SummaryRemoves unused and vulnerable TypeDiscriminatorConverter to eliminate a critical security risk that allowed arbitrary type instantiation during JSON deserialization. Critical IssuesNone found ✅ AnalysisThe PR correctly removes dead code that contained a serious security vulnerability. Verification confirms:
This is the correct solution - removing unused vulnerable code is better than hardening code that isn't needed. Verdict✅ APPROVE - No critical issues |
Summary
TypeDiscriminatorConverter<T>which contained a security vulnerabilityITypeDiscriminatorinterface as it's no longer usedBackground
The original approach was to add security restrictions (type allowlists) to the
TypeDiscriminatorConverter<T>. However, after PR #1895 refactoredModuleResultto use a discriminated union pattern withModuleResultJsonConverterFactory, nothing in the codebase implementsITypeDiscriminatoranymore.Since the vulnerable code is now dead code (unreachable), the simplest and safest fix is to remove it entirely rather than adding security restrictions to unused code.
Security Issue Fixed
The
TypeDiscriminatorConverter<T>could deserialize any type whose name matched theTypeDiscriminatorfield in JSON, potentially allowing malicious JSON to instantiate arbitrary types from any loaded assembly. This is a deserialization vulnerability that could lead to remote code execution.Test Plan
Fixes #1910
🤖 Generated with Claude Code