Skip to content

Commit 18b4939

Browse files
committed
Add MaxDepth handling
1 parent 3196915 commit 18b4939

File tree

5 files changed

+179
-69
lines changed

5 files changed

+179
-69
lines changed

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver
88
Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver.GetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo) -> Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo?
99
Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver.GetValidatableTypeInfo(System.Type! type) -> Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo?
1010
Microsoft.AspNetCore.Http.Validation.ValidatableContext
11+
Microsoft.AspNetCore.Http.Validation.ValidatableContext.CurrentDepth.get -> int
12+
Microsoft.AspNetCore.Http.Validation.ValidatableContext.CurrentDepth.set -> void
1113
Microsoft.AspNetCore.Http.Validation.ValidatableContext.Prefix.get -> string!
1214
Microsoft.AspNetCore.Http.Validation.ValidatableContext.Prefix.set -> void
1315
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidatableContext() -> void

src/Http/Http.Abstractions/src/Validation/ValidatableContext.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ public sealed class ValidatableContext
3535
/// </summary>
3636
public Dictionary<string, string[]>? ValidationErrors { get; set; }
3737

38+
/// <summary>
39+
/// Gets or sets the current depth in the validation hierarchy.
40+
/// This is used to prevent stack overflows from circular references.
41+
/// </summary>
42+
public int CurrentDepth { get; set; }
43+
3844
internal void AddValidationError(string key, string[] error)
3945
{
4046
ValidationErrors ??= [];

src/Http/Http.Abstractions/src/Validation/ValidatablePropertyInfo.cs

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -126,44 +126,64 @@ public Task Validate(object obj, ValidatableContext context)
126126
// Validate any other attributes
127127
ValidateValue(value, context.Prefix, validationAttributes);
128128

129-
// Handle enumerable values
130-
if (IsEnumerable && value is System.Collections.IEnumerable enumerable)
129+
// Check if we've reached the maximum depth before validating complex properties
130+
if (context.CurrentDepth >= context.ValidationOptions.MaxDepth)
131131
{
132-
var index = 0;
133-
var currentPrefix = context.Prefix;
132+
throw new InvalidOperationException(
133+
$"Maximum validation depth of {context.ValidationOptions.MaxDepth} exceeded at '{context.Prefix}'. " +
134+
"This is likely caused by a circular reference in the object graph. " +
135+
"Consider increasing the MaxDepth in ValidationOptions if deeper validation is required.");
136+
}
137+
138+
// Increment depth counter
139+
context.CurrentDepth++;
134140

135-
foreach (var item in enumerable)
141+
try
142+
{
143+
// Handle enumerable values
144+
if (IsEnumerable && value is System.Collections.IEnumerable enumerable)
136145
{
137-
context.Prefix = $"{currentPrefix}[{index}]";
146+
var index = 0;
147+
var currentPrefix = context.Prefix;
138148

139-
if (HasValidatableType && item != null)
149+
foreach (var item in enumerable)
140150
{
141-
var itemType = item.GetType();
142-
if (context.ValidationOptions.TryGetValidatableTypeInfo(itemType, out var validatableType))
151+
context.Prefix = $"{currentPrefix}[{index}]";
152+
153+
if (HasValidatableType && item != null)
143154
{
144-
validatableType.Validate(item, context);
155+
var itemType = item.GetType();
156+
if (context.ValidationOptions.TryGetValidatableTypeInfo(itemType, out var validatableType))
157+
{
158+
validatableType.Validate(item, context);
159+
}
145160
}
161+
162+
index++;
146163
}
147164

148-
index++;
165+
// Restore prefix to the property name before validating the next item
166+
context.Prefix = currentPrefix;
167+
}
168+
else if (HasValidatableType && value != null)
169+
{
170+
// Validate as a complex object
171+
var valueType = value.GetType();
172+
if (context.ValidationOptions.TryGetValidatableTypeInfo(valueType, out var validatableType))
173+
{
174+
validatableType.Validate(value, context);
175+
}
149176
}
150177

151-
// Restore prefix to the property name before validating the next item
152-
context.Prefix = currentPrefix;
178+
return Task.CompletedTask;
153179
}
154-
else if (HasValidatableType && value != null)
180+
finally
155181
{
156-
// Validate as a complex object
157-
var valueType = value.GetType();
158-
if (context.ValidationOptions.TryGetValidatableTypeInfo(valueType, out var validatableType))
159-
{
160-
validatableType.Validate(value, context);
161-
}
182+
// Always decrement the depth counter and restore prefix
183+
context.CurrentDepth--;
184+
context.Prefix = originalPrefix;
162185
}
163186

164-
// No need to restore prefix here as it will be restored by the calling method
165-
return Task.CompletedTask;
166-
167187
void ValidateValue(object? val, string errorPrefix, ValidationAttribute[] validationAttributes)
168188
{
169189
foreach (var attribute in validationAttributes)

src/Http/Http.Abstractions/src/Validation/ValidatableTypeInfo.cs

Lines changed: 59 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public ValidatableTypeInfo(
5555
/// Validates the specified value.
5656
/// </summary>
5757
/// <param name="value">The value to validate.</param>
58-
/// <param name="context"></param>
58+
/// <param name="context">The validation context.</param>
5959
public Task Validate(object? value, ValidatableContext context)
6060
{
6161
Debug.Assert(context.ValidationContext is not null);
@@ -64,65 +64,82 @@ public Task Validate(object? value, ValidatableContext context)
6464
return Task.CompletedTask;
6565
}
6666

67-
var actualType = value.GetType();
68-
var originalPrefix = context.Prefix;
69-
70-
// First validate members
71-
foreach (var member in Members)
67+
// Check if we've exceeded the maximum depth
68+
if (context.CurrentDepth >= context.ValidationOptions.MaxDepth)
7269
{
73-
member.Validate(value, context);
74-
context.Prefix = originalPrefix;
70+
throw new InvalidOperationException(
71+
$"Maximum validation depth of {context.ValidationOptions.MaxDepth} exceeded at '{context.Prefix}'. " +
72+
"This is likely caused by a circular reference in the object graph. " +
73+
"Consider increasing the MaxDepth in ValidationOptions if deeper validation is required.");
7574
}
7675

77-
// Then validate sub-types if any
78-
if (ValidatableSubTypes != null)
76+
try
7977
{
80-
foreach (var subType in ValidatableSubTypes)
78+
var actualType = value.GetType();
79+
var originalPrefix = context.Prefix;
80+
81+
// First validate members
82+
foreach (var member in Members)
8183
{
82-
if (subType.IsAssignableFrom(actualType))
84+
member.Validate(value, context);
85+
context.Prefix = originalPrefix;
86+
}
87+
88+
// Then validate sub-types if any
89+
if (ValidatableSubTypes != null)
90+
{
91+
foreach (var subType in ValidatableSubTypes)
8392
{
84-
if (context.ValidationOptions.TryGetValidatableTypeInfo(subType, out var subTypeInfo))
93+
if (subType.IsAssignableFrom(actualType))
8594
{
86-
subTypeInfo.Validate(value, context);
87-
context.Prefix = originalPrefix;
95+
if (context.ValidationOptions.TryGetValidatableTypeInfo(subType, out var subTypeInfo))
96+
{
97+
subTypeInfo.Validate(value, context);
98+
context.Prefix = originalPrefix;
99+
}
88100
}
89101
}
90102
}
91-
}
92103

93-
// Finally validate IValidatableObject if implemented
94-
if (IsIValidatableObject && value is IValidatableObject validatable)
95-
{
96-
// Important: Set the DisplayName to the type name for top-level validations
97-
// and restore the original validation context properties
98-
var originalDisplayName = context.ValidationContext.DisplayName;
99-
var originalMemberName = context.ValidationContext.MemberName;
104+
// Finally validate IValidatableObject if implemented
105+
if (IsIValidatableObject && value is IValidatableObject validatable)
106+
{
107+
// Important: Set the DisplayName to the type name for top-level validations
108+
// and restore the original validation context properties
109+
var originalDisplayName = context.ValidationContext.DisplayName;
110+
var originalMemberName = context.ValidationContext.MemberName;
100111

101-
// Set the display name to the class name for IValidatableObject validation
102-
context.ValidationContext.DisplayName = Type.Name;
103-
context.ValidationContext.MemberName = null;
112+
// Set the display name to the class name for IValidatableObject validation
113+
context.ValidationContext.DisplayName = Type.Name;
114+
context.ValidationContext.MemberName = null;
104115

105-
var validationResults = validatable.Validate(context.ValidationContext);
106-
foreach (var validationResult in validationResults)
107-
{
108-
if (validationResult != ValidationResult.Success)
116+
var validationResults = validatable.Validate(context.ValidationContext);
117+
foreach (var validationResult in validationResults)
109118
{
110-
var memberName = validationResult.MemberNames.First();
111-
var key = string.IsNullOrEmpty(originalPrefix) ?
112-
memberName :
113-
$"{originalPrefix}.{memberName}";
119+
if (validationResult != ValidationResult.Success)
120+
{
121+
var memberName = validationResult.MemberNames.First();
122+
var key = string.IsNullOrEmpty(originalPrefix) ?
123+
memberName :
124+
$"{originalPrefix}.{memberName}";
114125

115-
context.AddOrExtendValidationError(key, validationResult.ErrorMessage!);
126+
context.AddOrExtendValidationError(key, validationResult.ErrorMessage!);
127+
}
116128
}
129+
130+
// Restore the original validation context properties
131+
context.ValidationContext.DisplayName = originalDisplayName;
132+
context.ValidationContext.MemberName = originalMemberName;
117133
}
118134

119-
// Restore the original validation context properties
120-
context.ValidationContext.DisplayName = originalDisplayName;
121-
context.ValidationContext.MemberName = originalMemberName;
135+
// Always restore original prefix
136+
context.Prefix = originalPrefix;
137+
return Task.CompletedTask;
138+
}
139+
finally
140+
{
141+
// Decrement depth when validation completes
142+
context.CurrentDepth--;
122143
}
123-
124-
// Always restore original prefix
125-
context.Prefix = originalPrefix;
126-
return Task.CompletedTask;
127144
}
128145
}

src/Http/Http.Abstractions/test/ValidatableTypeInfoTests.cs

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,12 @@ [new RequiredAttribute()]),
200200
var order = new Order
201201
{
202202
OrderNumber = "ORD-12345",
203-
Items = new List<OrderItem>
204-
{
203+
Items =
204+
[
205205
new OrderItem { ProductName = "Valid Product", Quantity = 5 },
206206
new OrderItem { /* Missing ProductName (required) */ Quantity = 0 /* Invalid quantity */ },
207207
new OrderItem { ProductName = "Another Product", Quantity = 200 /* Invalid quantity */ }
208-
}
208+
]
209209
};
210210
context.ValidationContext = new ValidationContext(order);
211211

@@ -257,6 +257,64 @@ public async Task Validate_HandlesNullValues_Appropriately()
257257
Assert.Null(context.ValidationErrors); // No validation errors for nullable properties with null values
258258
}
259259

260+
[Fact]
261+
public async Task Validate_RespectsMaxDepthOption_ForCircularReferences()
262+
{
263+
// Arrange
264+
// Create a type that can contain itself (circular reference)
265+
var nodeType = new TestValidatableTypeInfo(
266+
typeof(TreeNode),
267+
[
268+
CreatePropertyInfo(typeof(TreeNode), typeof(string), "Name", "Name", false, false, true, false,
269+
[new RequiredAttribute()]),
270+
CreatePropertyInfo(typeof(TreeNode), typeof(TreeNode), "Parent", "Parent", false, true, false, true,
271+
[]),
272+
CreatePropertyInfo(typeof(TreeNode), typeof(List<TreeNode>), "Children", "Children", true, false, false, true,
273+
[])
274+
],
275+
false
276+
);
277+
278+
// Create a validation options with a small max depth
279+
var validationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
280+
{
281+
{ typeof(TreeNode), nodeType }
282+
});
283+
validationOptions.MaxDepth = 3; // Set a small max depth to trigger the limit
284+
285+
var context = new ValidatableContext
286+
{
287+
ValidationOptions = validationOptions,
288+
ValidationErrors = []
289+
};
290+
291+
// Create a deep tree with circular references
292+
var rootNode = new TreeNode { Name = "Root" };
293+
var level1 = new TreeNode { Name = "Level1", Parent = rootNode };
294+
var level2 = new TreeNode { Name = "Level2", Parent = level1 };
295+
var level3 = new TreeNode { Name = "Level3", Parent = level2 };
296+
var level4 = new TreeNode { Name = "" }; // Invalid: missing required name
297+
var level5 = new TreeNode { Name = "" }; // Invalid but beyond max depth, should not be validated
298+
299+
rootNode.Children.Add(level1);
300+
level1.Children.Add(level2);
301+
level2.Children.Add(level3);
302+
level3.Children.Add(level4);
303+
level4.Children.Add(level5);
304+
305+
// Add a circular reference
306+
level5.Children.Add(rootNode);
307+
308+
context.ValidationContext = new ValidationContext(rootNode);
309+
310+
// Act + Assert
311+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
312+
async () => await nodeType.Validate(rootNode, context));
313+
314+
Assert.NotNull(exception);
315+
Assert.Contains("Maximum validation depth of 3 exceeded at 'Children[0].Parent.Children[0]'. This is likely caused by a circular reference in the object graph. Consider increasing the MaxDepth in ValidationOptions if deeper validation is required.", exception.Message);
316+
}
317+
260318
private ValidatablePropertyInfo CreatePropertyInfo(
261319
Type containingType,
262320
Type propertyType,
@@ -323,7 +381,7 @@ private class Car : Vehicle
323381
private class Order
324382
{
325383
public string? OrderNumber { get; set; }
326-
public List<OrderItem> Items { get; set; } = new List<OrderItem>();
384+
public List<OrderItem> Items { get; set; } = [];
327385
}
328386

329387
private class OrderItem
@@ -332,6 +390,13 @@ private class OrderItem
332390
public int Quantity { get; set; }
333391
}
334392

393+
private class TreeNode
394+
{
395+
public string Name { get; set; } = string.Empty;
396+
public TreeNode? Parent { get; set; }
397+
public List<TreeNode> Children { get; set; } = [];
398+
}
399+
335400
// Test implementations
336401
private class TestValidatablePropertyInfo : ValidatablePropertyInfo
337402
{

0 commit comments

Comments
 (0)