Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions JsonApiToolkit.Tests/Extensions/RecursionDepthGuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public void BuildFilterExpression_WithShallowNesting_Succeeds()
var parameter = Expression.Parameter(typeof(Level0), "x");

// This should not throw
var expression = NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
var expression = PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);

Assert.NotNull(expression);
}
Expand All @@ -83,7 +83,7 @@ public void BuildFilterExpression_WithDeeplyNestedCollections_ThrowsBadRequest()
var parameter = Expression.Parameter(typeof(Level0), "x");

var exception = Assert.Throws<JsonApiBadRequestException>(() =>
NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
);

Assert.Contains("recursion depth", exception.Message.ToLower());
Expand All @@ -105,7 +105,7 @@ public void BuildFilterExpression_AtExactLimit_Succeeds()
var parameter = Expression.Parameter(typeof(Level0), "x");

// This should not throw - exactly at limit
var expression = NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
var expression = PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);

Assert.NotNull(expression);
}
Expand All @@ -124,7 +124,7 @@ public void BuildFilterExpression_JustOverLimit_ThrowsBadRequest()
var parameter = Expression.Parameter(typeof(Level0), "x");

var exception = Assert.Throws<JsonApiBadRequestException>(() =>
NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
);

Assert.Contains("recursion depth", exception.Message.ToLower());
Expand All @@ -143,7 +143,7 @@ public void BuildFilterExpression_ErrorMetadata_ContainsFieldInfo()
var parameter = Expression.Parameter(typeof(Level0), "x");

var exception = Assert.Throws<JsonApiBadRequestException>(() =>
NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
);

Assert.NotNull(exception.ErrorSource);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@

namespace JsonApiToolkit.Extensions.Querying;

internal static partial class NestedPropertyNavigator
/// <summary>
/// Builds LINQ expressions for collection navigations: <c>Any(item =&gt; predicate)</c>
/// for nested paths (e.g. <c>tags.name</c>) and <c>Contains(value)</c> for primitive
/// collections used as filter targets (e.g. <c>filter[tags][in]=value</c>).
/// </summary>
internal static class CollectionFilterBuilder
{
/// <summary>
/// Builds a filter expression for collection navigation using Any().
/// e.g., collection.Any(item => item.Property == value)
/// e.g., collection.Any(item =&gt; item.Property == value)
/// </summary>
private static Expression? BuildCollectionFilterExpression(
internal static Expression? BuildCollectionFilterExpression(
Expression collectionAccess,
Type elementType,
string[] remainingParts,
Expand All @@ -22,17 +27,17 @@ internal static partial class NestedPropertyNavigator
int depth
)
{
if (depth > MaxRecursionDepth)
if (depth > PropertyNavigator.MaxRecursionDepth)
{
throw new JsonApiBadRequestException(
$"Filter path recursion depth exceeds maximum of {MaxRecursionDepth}. "
$"Filter path recursion depth exceeds maximum of {PropertyNavigator.MaxRecursionDepth}. "
+ "Simplify the filter expression or reduce collection nesting.",
JsonApiErrorCodes.QueryTooComplex,
new ErrorSource { Parameter = $"filter[{filter.Field}]" },
new Dictionary<string, object>
{
["field"] = filter.Field,
["maxDepth"] = MaxRecursionDepth,
["maxDepth"] = PropertyNavigator.MaxRecursionDepth,
["actualDepth"] = depth,
}
);
Expand Down Expand Up @@ -65,12 +70,16 @@ int depth
}

Expression propertyAccess = Expression.Property(itemParam, prop);
innerExpression = BuildPropertyFilterExpression(propertyAccess, filter, logger);
innerExpression = PropertyFilterBuilder.BuildPropertyFilterExpression(
propertyAccess,
filter,
logger
);
}
else
{
// Nested property access - recursively build
innerExpression = BuildSafeNestedFilterExpression(
innerExpression = PropertyNavigator.BuildSafeNestedFilterExpression(
itemParam,
innerFilter,
logger,
Expand All @@ -95,7 +104,7 @@ int depth
/// Builds a filter expression when the property itself is a collection.
/// e.g., entity.Tags.Contains("value") for filter[tags][in]=value
/// </summary>
private static Expression? BuildCollectionPropertyFilterExpression(
internal static Expression? BuildCollectionPropertyFilterExpression(
Expression collectionAccess,
Type elementType,
FilterParameter filter,
Expand Down Expand Up @@ -146,7 +155,7 @@ int depth
{
logger?.LogWarning(
"Failed to convert '{Value}' to {ElementType} for collection filter",
SanitizeForLog(filter.Value),
FilterLogSanitizer.SanitizeForLog(filter.Value),
elementType.Name
);
return null;
Expand All @@ -172,7 +181,7 @@ int depth
{
logger?.LogWarning(
"Failed to convert '{Value}' to {ElementType} for collection filter",
SanitizeForLog(filter.Value),
FilterLogSanitizer.SanitizeForLog(filter.Value),
elementType.Name
);
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,7 @@ public static class FilterExpressionBuilder
)
{
if (filter.Field.Contains('.'))
return NestedPropertyNavigator.BuildSafeNestedFilterExpression(
parameter,
filter,
logger
);
return PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter, logger);

PropertyInfo? property = QueryHelpers.GetPropertyByJsonName(parameter.Type, filter.Field);
if (property == null)
Expand All @@ -141,10 +137,6 @@ public static class FilterExpressionBuilder
}

Expression propertyAccess = Expression.Property(parameter, property);
return NestedPropertyNavigator.BuildPropertyFilterExpression(
propertyAccess,
filter,
logger
);
return PropertyFilterBuilder.BuildPropertyFilterExpression(propertyAccess, filter, logger);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.RegularExpressions;

namespace JsonApiToolkit.Extensions.Querying;

internal static partial class FilterLogSanitizer
{
private const int MaxLogValueLength = 100;

/// <summary>
/// Sanitizes user input for safe logging by removing control characters
/// and truncating long values to prevent log forging attacks.
/// </summary>
internal static string SanitizeForLog(string? value)
{
if (string.IsNullOrEmpty(value))
return "(empty)";

// Remove control characters (newlines, tabs, etc.) that could forge log entries
string sanitized = ControlCharRegex().Replace(value, " ");

// Truncate long values
if (sanitized.Length > MaxLogValueLength)
return string.Concat(sanitized.AsSpan(0, MaxLogValueLength), "...(truncated)");

return sanitized;
}

[GeneratedRegex(@"[\x00-\x1F\x7F]")]
private static partial Regex ControlCharRegex();
}
Loading
Loading