diff --git a/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs index 4eaeb9baa..a05b29711 100644 --- a/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs @@ -74,7 +74,7 @@ public static IServiceCollection AddDefaultWebApiServices(this IServiceCollectio // QueryValidators. services.AddSingleton(); - + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index 892740cad..b54026510 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -11586,6 +11586,13 @@ The query context. The built . + + + Gets the . + + The query context. + The built . + Gets the . @@ -12568,6 +12575,17 @@ Gets the raw $apply value. + + + Gets or sets the $apply Query Validator. + + + + + Validate the $apply query based on the given . It throws an ODataException if validation failed. + + The instance which contains all the validation settings. + Apply the apply query to the given IQueryable. @@ -13368,6 +13386,24 @@ The remaining segments after the first non type segment. First non-type cast segment. + + + Represents a validator used to validate an based on the . + + + The filter transformation is validated with the same used by + $filter, so a property marked as not filterable is rejected consistently. The groupby, + aggregate and compute transformations reject any referenced property that the model marks as + not filterable or configures as not selectable, consistent with $filter and $select. + + + + + Validates an . + + The $apply query. + The validation settings. + Represents a validator used to validate a based on the . @@ -13646,6 +13682,41 @@ The single value node. The validator context. + + + Validates that a function is allowed by the allow-list. + + + Shared with the $apply (groupby/aggregate/compute) and $compute node walk so those + transformations enforce the same function allow-list as $filter. + + The function name. + The validation settings. + + + + Validates that a binary operator is allowed by the + or allow-lists. + + + Shared with the $apply (groupby/aggregate/compute) and $compute node walk so those + transformations enforce the same operator allow-lists as $filter. + + The binary operator node. + The validation settings. + + + + Validates that a unary operator ('not'/negate) is allowed by the + allow-list. + + + Shared with the $apply (groupby/aggregate/compute) and $compute node walk so those + transformations enforce the same operator allow-list as $filter. + + The unary operator node. + The validation settings. + The metadata context for $filter validator. @@ -13689,6 +13760,19 @@ Exit lambda expression. + + + Provide the interface used to validate an + based on the . + + + + + Validates the OData query. + + The $apply query. + The validation settings. + Provide the interface used to validate a @@ -14188,6 +14272,26 @@ Thrown if does not implement the required interface or inherit from the required base class. + + + Walks the query nodes referenced by $apply (groupby/aggregate/compute) and top-level + $compute expressions and rejects any property that the model marks as not filterable or + configures as not selectable, so those properties are enforced consistently with how they are + enforced for $filter and $select. The same walk also enforces the operator and + function allow-lists and the node-count limit from , so those + limits apply to the groupby/aggregate/compute/$compute expressions exactly as they do to $filter. + + + + + Validates a single and its descendants. + + The query node to validate. A null node is a no-op. + The query context used to resolve the model and query configurations. + + The validation settings whose operator/function allow-lists and node-count/depth limits are enforced. + + The base for validator context. diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index 559ea265b..0f48d063c 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -4,9 +4,17 @@ Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.MatchesPatternTimeoutMilli Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.MatchesPatternTimeoutMilliseconds.set -> void Microsoft.AspNetCore.OData.Query.ODataQuerySettings.MatchesPatternTimeout.get -> System.TimeSpan? Microsoft.AspNetCore.OData.Query.ODataQuerySettings.MatchesPatternTimeout.set -> void +Microsoft.AspNetCore.OData.Query.ApplyQueryOption.Validate(Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void +Microsoft.AspNetCore.OData.Query.ApplyQueryOption.Validator.get -> Microsoft.AspNetCore.OData.Query.Validator.IApplyQueryValidator +Microsoft.AspNetCore.OData.Query.ApplyQueryOption.Validator.set -> void Microsoft.AspNetCore.OData.Query.ODataQuerySettings.MaxFunctionCallDepth.get -> int Microsoft.AspNetCore.OData.Query.ODataQuerySettings.MaxFunctionCallDepth.set -> void +Microsoft.AspNetCore.OData.Query.Validator.ApplyQueryValidator +Microsoft.AspNetCore.OData.Query.Validator.ApplyQueryValidator.ApplyQueryValidator() -> void +Microsoft.AspNetCore.OData.Query.Validator.IApplyQueryValidator +Microsoft.AspNetCore.OData.Query.Validator.IApplyQueryValidator.Validate(Microsoft.AspNetCore.OData.Query.ApplyQueryOption applyQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void Microsoft.AspNetCore.OData.ODataMiniOptions.MaxReceivedMessageSize.get -> long Microsoft.AspNetCore.OData.ODataMiniOptions.SetMaxReceivedMessageSize(long maxReceivedMessageSize) -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataOptions.MaxReceivedMessageSize.get -> long Microsoft.AspNetCore.OData.ODataOptions.MaxReceivedMessageSize.set -> void +virtual Microsoft.AspNetCore.OData.Query.Validator.ApplyQueryValidator.Validate(Microsoft.AspNetCore.OData.Query.ApplyQueryOption applyQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void diff --git a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs index 2cbc52673..2effbcfdd 100644 --- a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs @@ -183,6 +183,17 @@ public static IODataQueryValidator GetODataQueryValidator(this ODataQueryContext ?? new ODataQueryValidator(); } + /// + /// Gets the . + /// + /// The query context. + /// The built . + public static IApplyQueryValidator GetApplyQueryValidator(this ODataQueryContext context) + { + return context?.RequestContainer?.GetService() + ?? new ApplyQueryValidator(); + } + /// /// Gets the . /// diff --git a/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs b/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs index 256988750..55f49d8e2 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs @@ -6,11 +6,12 @@ //------------------------------------------------------------------------------ using System; +using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Linq; -using System.Linq.Expressions; using Microsoft.AspNetCore.OData.Abstracts; using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.Query.Validator; using Microsoft.Extensions.DependencyInjection; using Microsoft.OData.Edm; using Microsoft.OData.ModelBuilder; @@ -53,17 +54,39 @@ public ApplyQueryOption(string rawValue, ODataQueryContext context, ODataQueryOp RawValue = rawValue; Context = context; - // TODO: Implement and add validator - //Validator = new FilterQueryValidator(); + Validator = context.GetApplyQueryValidator(); _queryOptionParser = queryOptionParser; ResultClrType = Context.ElementClrType; } - // for unit test only + // This constructor is intended for unit testing only. internal ApplyQueryOption(string rawValue, ODataQueryContext context) { + if (string.IsNullOrEmpty(rawValue)) + { + throw Error.ArgumentNullOrEmpty(nameof(rawValue)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + RawValue = rawValue; Context = context; + Validator = context.GetApplyQueryValidator(); + _queryOptionParser = new ODataQueryOptionParser( + context.Model, + context.ElementType, + context.NavigationSource, + new Dictionary { { "$apply", rawValue } }, + context.RequestContainer); + + if (context.RequestContainer == null) + { + // By default, let's enable the property name case-insensitive + _queryOptionParser.Resolver = ODataQueryContext.DefaultCaseInsensitiveResolver; + } } /// @@ -97,6 +120,28 @@ public ApplyClause ApplyClause /// public string RawValue { get; private set; } + /// + /// Gets or sets the $apply Query Validator. + /// + public IApplyQueryValidator Validator { get; set; } + + /// + /// Validate the $apply query based on the given . It throws an ODataException if validation failed. + /// + /// The instance which contains all the validation settings. + public void Validate(ODataValidationSettings validationSettings) + { + if (validationSettings == null) + { + throw Error.ArgumentNull(nameof(validationSettings)); + } + + if (Validator != null) + { + Validator.Validate(this, validationSettings); + } + } + /// /// Apply the apply query to the given IQueryable. /// diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/ApplyQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/ApplyQueryValidator.cs new file mode 100644 index 000000000..40ad08a39 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/ApplyQueryValidator.cs @@ -0,0 +1,160 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.UriParser; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.Query.Validator; + +/// +/// Represents a validator used to validate an based on the . +/// +/// +/// The filter transformation is validated with the same used by +/// $filter, so a property marked as not filterable is rejected consistently. The groupby, +/// aggregate and compute transformations reject any referenced property that the model marks as +/// not filterable or configures as not selectable, consistent with $filter and $select. +/// +public class ApplyQueryValidator : IApplyQueryValidator +{ + /// + /// Validates an . + /// + /// The $apply query. + /// The validation settings. + public virtual void Validate(ApplyQueryOption applyQueryOption, ODataValidationSettings validationSettings) + { + if (applyQueryOption == null) + { + throw Error.ArgumentNull(nameof(applyQueryOption)); + } + + if (validationSettings == null) + { + throw Error.ArgumentNull(nameof(validationSettings)); + } + + ApplyClause applyClause = applyQueryOption.ApplyClause; + if (applyClause == null) + { + return; + } + + foreach (TransformationNode transformation in applyClause.Transformations) + { + ValidateTransformation(transformation, applyQueryOption.Context, validationSettings); + } + } + + private static void ValidateTransformation(TransformationNode transformation, ODataQueryContext context, ODataValidationSettings validationSettings) + { + if (transformation == null) + { + return; + } + + switch (transformation.Kind) + { + case TransformationNodeKind.Filter: + FilterTransformationNode filterTransformation = (FilterTransformationNode)transformation; + + // Validate the filter transformation with the same validator used by $filter so that + // not-filterable properties and the configured limits are enforced consistently. + FilterQueryOption filterQueryOption = new FilterQueryOption(context, filterTransformation.FilterClause); + filterQueryOption.Validate(validationSettings); + break; + + case TransformationNodeKind.GroupBy: + GroupByTransformationNode groupByTransformation = (GroupByTransformationNode)transformation; + if (groupByTransformation.GroupingProperties != null) + { + foreach (GroupByPropertyNode groupingProperty in groupByTransformation.GroupingProperties) + { + ValidateGroupByPropertyNode(groupingProperty, context, validationSettings); + } + } + + ValidateTransformation(groupByTransformation.ChildTransformations, context, validationSettings); + break; + + case TransformationNodeKind.Aggregate: + AggregateTransformationNode aggregateTransformation = (AggregateTransformationNode)transformation; + if (aggregateTransformation.AggregateExpressions != null) + { + foreach (AggregateExpressionBase aggregateExpression in aggregateTransformation.AggregateExpressions) + { + ValidateAggregateExpression(aggregateExpression, context, validationSettings); + } + } + break; + + case TransformationNodeKind.Compute: + ComputeTransformationNode computeTransformation = (ComputeTransformationNode)transformation; + if (computeTransformation.Expressions != null) + { + foreach (ComputeExpression computeExpression in computeTransformation.Expressions) + { + QueryNodeRestrictionValidator.Validate(computeExpression.Expression, context, validationSettings); + } + } + break; + + default: + // Only the aggregate, groupby, compute and filter transformations are bound and + // executed by ApplyQueryOptions.ApplyTo, so those are the only kinds that can + // contribute a referenced property to enforce. Any other transformation kind is + // intentionally not walked here. + break; + } + } + + private static void ValidateGroupByPropertyNode(GroupByPropertyNode groupingProperty, ODataQueryContext context, ODataValidationSettings validationSettings) + { + if (groupingProperty == null) + { + return; + } + + if (groupingProperty.Expression != null) + { + QueryNodeRestrictionValidator.Validate(groupingProperty.Expression, context, validationSettings); + } + + if (groupingProperty.ChildTransformations != null) + { + foreach (GroupByPropertyNode childProperty in groupingProperty.ChildTransformations) + { + ValidateGroupByPropertyNode(childProperty, context, validationSettings); + } + } + } + + private static void ValidateAggregateExpression(AggregateExpressionBase aggregateExpression, ODataQueryContext context, ODataValidationSettings validationSettings) + { + if (aggregateExpression == null) + { + return; + } + + if (aggregateExpression is AggregateExpression singleAggregateExpression) + { + QueryNodeRestrictionValidator.Validate(singleAggregateExpression.Expression, context, validationSettings); + } + else if (aggregateExpression is EntitySetAggregateExpression entitySetAggregateExpression) + { + QueryNodeRestrictionValidator.Validate(entitySetAggregateExpression.Expression, context, validationSettings); + + if (entitySetAggregateExpression.Children != null) + { + foreach (AggregateExpressionBase child in entitySetAggregateExpression.Children) + { + ValidateAggregateExpression(child, context, validationSettings); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/ComputeQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/ComputeQueryValidator.cs index 75921c8fd..1671a645c 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Validator/ComputeQueryValidator.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/ComputeQueryValidator.cs @@ -5,6 +5,8 @@ // //------------------------------------------------------------------------------ +using Microsoft.OData.UriParser; + namespace Microsoft.AspNetCore.OData.Query.Validator; /// @@ -29,10 +31,17 @@ public virtual void Validate(ComputeQueryOption computeQueryOption, ODataValidat throw Error.ArgumentNull(nameof(validationSettings)); } - // so far, we don't have validation rules here for $compute - // because 'DefaultQuerySetting' doesn't have configuration for $compute - // we can only let ODL to parse and verify the compute clause, - // however, developer can override this method add his own rules - _ = computeQueryOption.ComputeClause; + // Reject any property referenced by a $compute expression that the model marks as not + // filterable or configures as not selectable, so those properties are enforced + // consistently with $filter and $select. Developers can override this method to add + // their own rules. + ComputeClause computeClause = computeQueryOption.ComputeClause; + if (computeClause != null) + { + foreach (ComputeExpression computeExpression in computeClause.ComputedItems) + { + QueryNodeRestrictionValidator.Validate(computeExpression.Expression, computeQueryOption.Context, validationSettings); + } + } } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs index 92d3c5e16..c1bf75d5b 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs @@ -736,14 +736,106 @@ private static void ValidateFunction(string functionName, FilterValidatorContext { Contract.Assert(validatorContext != null); + ValidateFunctionAllowed(functionName, validatorContext.ValidationSettings); + } + + /// + /// Validates that a function is allowed by the allow-list. + /// + /// + /// Shared with the $apply (groupby/aggregate/compute) and $compute node walk so those + /// transformations enforce the same function allow-list as $filter. + /// + /// The function name. + /// The validation settings. + internal static void ValidateFunctionAllowed(string functionName, ODataValidationSettings validationSettings) + { + Contract.Assert(validationSettings != null); + AllowedFunctions convertedFunction = ToODataFunction(functionName); - if ((validatorContext.ValidationSettings.AllowedFunctions & convertedFunction) != convertedFunction) + if ((validationSettings.AllowedFunctions & convertedFunction) != convertedFunction) { // this means the given function is not allowed throw new ODataException(Error.Format(SRResources.NotAllowedFunction, functionName, "AllowedFunctions")); } } + /// + /// Validates that a binary operator is allowed by the + /// or allow-lists. + /// + /// + /// Shared with the $apply (groupby/aggregate/compute) and $compute node walk so those + /// transformations enforce the same operator allow-lists as $filter. + /// + /// The binary operator node. + /// The validation settings. + internal static void ValidateBinaryOperatorAllowed(BinaryOperatorNode binaryOperatorNode, ODataValidationSettings validationSettings) + { + Contract.Assert(binaryOperatorNode != null); + Contract.Assert(validationSettings != null); + + switch (binaryOperatorNode.OperatorKind) + { + case BinaryOperatorKind.Equal: + case BinaryOperatorKind.NotEqual: + case BinaryOperatorKind.And: + case BinaryOperatorKind.GreaterThan: + case BinaryOperatorKind.GreaterThanOrEqual: + case BinaryOperatorKind.LessThan: + case BinaryOperatorKind.LessThanOrEqual: + case BinaryOperatorKind.Or: + case BinaryOperatorKind.Has: + AllowedLogicalOperators logicalOperator = ToLogicalOperator(binaryOperatorNode); + if ((validationSettings.AllowedLogicalOperators & logicalOperator) != logicalOperator) + { + throw new ODataException(Error.Format(SRResources.NotAllowedLogicalOperator, logicalOperator, "AllowedLogicalOperators")); + } + + break; + + default: + AllowedArithmeticOperators arithmeticOperator = ToArithmeticOperator(binaryOperatorNode); + if ((validationSettings.AllowedArithmeticOperators & arithmeticOperator) != arithmeticOperator) + { + throw new ODataException(Error.Format(SRResources.NotAllowedArithmeticOperator, arithmeticOperator, "AllowedArithmeticOperators")); + } + + break; + } + } + + /// + /// Validates that a unary operator ('not'/negate) is allowed by the + /// allow-list. + /// + /// + /// Shared with the $apply (groupby/aggregate/compute) and $compute node walk so those + /// transformations enforce the same operator allow-list as $filter. + /// + /// The unary operator node. + /// The validation settings. + internal static void ValidateUnaryOperatorAllowed(UnaryOperatorNode unaryOperatorNode, ODataValidationSettings validationSettings) + { + Contract.Assert(unaryOperatorNode != null); + Contract.Assert(validationSettings != null); + + switch (unaryOperatorNode.OperatorKind) + { + case UnaryOperatorKind.Negate: + case UnaryOperatorKind.Not: + if ((validationSettings.AllowedLogicalOperators & AllowedLogicalOperators.Not) != AllowedLogicalOperators.Not) + { + throw new ODataException(Error.Format(SRResources.NotAllowedLogicalOperator, unaryOperatorNode.OperatorKind, "AllowedLogicalOperators")); + } + + break; + + default: + throw Error.NotSupported(SRResources.UnaryNodeValidationNotSupported, unaryOperatorNode.OperatorKind, typeof(FilterQueryValidator).Name); + } + } + private static AllowedFunctions ToODataFunction(string functionName) { AllowedFunctions result = AllowedFunctions.None; diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/Interfaces/IApplyQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/Interfaces/IApplyQueryValidator.cs new file mode 100644 index 000000000..64e5f855d --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/Interfaces/IApplyQueryValidator.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.Query.Validator; + +/// +/// Provide the interface used to validate an +/// based on the . +/// +public interface IApplyQueryValidator +{ + /// + /// Validates the OData query. + /// + /// The $apply query. + /// The validation settings. + void Validate(ApplyQueryOption applyQueryOption, ODataValidationSettings validationSettings); +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs index 1b252a4da..601c6c131 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs @@ -42,6 +42,7 @@ public virtual void Validate(ODataQueryOptions options, ODataValidationSettings if (options.Apply?.ApplyClause != null) { ValidateQueryOptionAllowed(AllowedQueryOptions.Apply, validationSettings.AllowedQueryOptions); + options.Apply.Validate(validationSettings); } if (options.Skip != null) diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/QueryNodeRestrictionValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/QueryNodeRestrictionValidator.cs new file mode 100644 index 000000000..f0b7ca592 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/QueryNodeRestrictionValidator.cs @@ -0,0 +1,244 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.OData.Query.Validator; + +/// +/// Walks the query nodes referenced by $apply (groupby/aggregate/compute) and top-level +/// $compute expressions and rejects any property that the model marks as not filterable or +/// configures as not selectable, so those properties are enforced consistently with how they are +/// enforced for $filter and $select. The same walk also enforces the operator and +/// function allow-lists and the node-count limit from , so those +/// limits apply to the groupby/aggregate/compute/$compute expressions exactly as they do to $filter. +/// +internal static class QueryNodeRestrictionValidator +{ + /// + /// Validates a single and its descendants. + /// + /// The query node to validate. A null node is a no-op. + /// The query context used to resolve the model and query configurations. + /// + /// The validation settings whose operator/function allow-lists and node-count/depth limits are enforced. + /// + internal static void Validate(QueryNode node, ODataQueryContext context, ODataValidationSettings validationSettings) + { + // A FilterValidatorContext carries the same node-count and lambda-depth bookkeeping used by + // $filter, so the compute/aggregate/groupby walk enforces MaxNodeCount and + // MaxAnyAllExpressionDepth identically. Function-call depth is enforced at bind time. + FilterValidatorContext validatorContext = new FilterValidatorContext + { + Context = context, + ValidationSettings = validationSettings, + Property = context.TargetProperty, + StructuredType = context.TargetStructuredType, + CurrentDepth = 0 + }; + + Validate(node, validatorContext); + } + + private static void Validate(QueryNode node, FilterValidatorContext validatorContext) + { + if (node == null) + { + return; + } + + ODataQueryContext context = validatorContext.Context; + ODataValidationSettings validationSettings = validatorContext.ValidationSettings; + + // Count every visited node against MaxNodeCount, consistent with $filter. + validatorContext.IncrementNodeCount(); + + switch (node.Kind) + { + case QueryNodeKind.BinaryOperator: + BinaryOperatorNode binaryOperatorNode = (BinaryOperatorNode)node; + FilterQueryValidator.ValidateBinaryOperatorAllowed(binaryOperatorNode, validationSettings); + Validate(binaryOperatorNode.Left, validatorContext); + Validate(binaryOperatorNode.Right, validatorContext); + break; + + case QueryNodeKind.UnaryOperator: + UnaryOperatorNode unaryOperatorNode = (UnaryOperatorNode)node; + FilterQueryValidator.ValidateUnaryOperatorAllowed(unaryOperatorNode, validationSettings); + Validate(unaryOperatorNode.Operand, validatorContext); + break; + + case QueryNodeKind.Convert: + Validate(((ConvertNode)node).Source, validatorContext); + break; + + case QueryNodeKind.SingleValuePropertyAccess: + SingleValuePropertyAccessNode singleValuePropertyAccessNode = (SingleValuePropertyAccessNode)node; + CheckProperty(singleValuePropertyAccessNode.Property, context, checkSelectable: true); + Validate(singleValuePropertyAccessNode.Source, validatorContext); + break; + + case QueryNodeKind.CollectionPropertyAccess: + CollectionPropertyAccessNode collectionPropertyAccessNode = (CollectionPropertyAccessNode)node; + CheckProperty(collectionPropertyAccessNode.Property, context, checkSelectable: true); + Validate(collectionPropertyAccessNode.Source, validatorContext); + break; + + case QueryNodeKind.SingleComplexNode: + SingleComplexNode singleComplexNode = (SingleComplexNode)node; + CheckProperty(singleComplexNode.Property, context, checkSelectable: true); + Validate(singleComplexNode.Source, validatorContext); + break; + + case QueryNodeKind.CollectionComplexNode: + CollectionComplexNode collectionComplexNode = (CollectionComplexNode)node; + CheckProperty(collectionComplexNode.Property, context, checkSelectable: true); + Validate(collectionComplexNode.Source, validatorContext); + break; + + case QueryNodeKind.AggregatedCollectionPropertyNode: + AggregatedCollectionPropertyNode aggregatedCollectionPropertyNode = (AggregatedCollectionPropertyNode)node; + CheckProperty(aggregatedCollectionPropertyNode.Property, context, checkSelectable: true); + Validate(aggregatedCollectionPropertyNode.Source, validatorContext); + break; + + case QueryNodeKind.SingleNavigationNode: + SingleNavigationNode singleNavigationNode = (SingleNavigationNode)node; + CheckProperty(singleNavigationNode.NavigationProperty, context, checkSelectable: false); + Validate(singleNavigationNode.Source, validatorContext); + break; + + case QueryNodeKind.CollectionNavigationNode: + CollectionNavigationNode collectionNavigationNode = (CollectionNavigationNode)node; + CheckProperty(collectionNavigationNode.NavigationProperty, context, checkSelectable: false); + Validate(collectionNavigationNode.Source, validatorContext); + break; + + case QueryNodeKind.SingleValueFunctionCall: + ValidateFunctionCall(((SingleValueFunctionCallNode)node).Name, ((SingleValueFunctionCallNode)node).Parameters, validatorContext); + break; + + case QueryNodeKind.CollectionFunctionCall: + ValidateFunctionCall(((CollectionFunctionCallNode)node).Name, ((CollectionFunctionCallNode)node).Parameters, validatorContext); + break; + + case QueryNodeKind.SingleResourceFunctionCall: + ValidateFunctionCall(((SingleResourceFunctionCallNode)node).Name, ((SingleResourceFunctionCallNode)node).Parameters, validatorContext); + break; + + case QueryNodeKind.CollectionResourceFunctionCall: + ValidateFunctionCall(((CollectionResourceFunctionCallNode)node).Name, ((CollectionResourceFunctionCallNode)node).Parameters, validatorContext); + break; + + case QueryNodeKind.Any: + AnyNode anyNode = (AnyNode)node; + FilterQueryValidator.ValidateFunctionAllowed("any", validationSettings); + validatorContext.EnterLambda(); + try + { + Validate(anyNode.Source, validatorContext); + Validate(anyNode.Body, validatorContext); + } + finally + { + validatorContext.ExitLambda(); + } + + break; + + case QueryNodeKind.All: + AllNode allNode = (AllNode)node; + FilterQueryValidator.ValidateFunctionAllowed("all", validationSettings); + validatorContext.EnterLambda(); + try + { + Validate(allNode.Source, validatorContext); + Validate(allNode.Body, validatorContext); + } + finally + { + validatorContext.ExitLambda(); + } + + break; + + case QueryNodeKind.SingleResourceCast: + Validate(((SingleResourceCastNode)node).Source, validatorContext); + break; + + case QueryNodeKind.CollectionResourceCast: + Validate(((CollectionResourceCastNode)node).Source, validatorContext); + break; + + case QueryNodeKind.In: + InNode inNode = (InNode)node; + Validate(inNode.Left, validatorContext); + Validate(inNode.Right, validatorContext); + break; + + case QueryNodeKind.Count: + // A real CountNode ($count over a collection) exposes a Source to walk. The virtual + // $count used inside aggregate($count as ...) reports the same Count kind but has no + // underlying property to restrict, so it is left as a no-op. + if (node is CountNode countNode) + { + Validate(countNode.Source, validatorContext); + } + + break; + + default: + break; + } + } + + private static void ValidateFunctionCall(string functionName, IEnumerable parameters, FilterValidatorContext validatorContext) + { + // Enforce the function allow-list, consistent with $filter. Function-call depth is enforced at + // bind time (QueryBinderContext) on this branch, so it is not tracked during validation here. + FilterQueryValidator.ValidateFunctionAllowed(functionName, validatorContext.ValidationSettings); + foreach (QueryNode parameter in parameters) + { + Validate(parameter, validatorContext); + } + } + + private static void CheckProperty(IEdmProperty property, ODataQueryContext context, bool checkSelectable) + { + if (property == null) + { + return; + } + + IEdmModel model = context.Model; + + // Enforce only the explicit, per-property restrictions ([NotFilterable]/[NotSelectable] and + // model-bound disabled configurations) by passing the enable flags as true. This keeps the + // groupby/aggregate/compute walk selective: a property that is not explicitly restricted stays + // available regardless of the global EnableFilter/EnableSelect switches, so enabling this + // validation is a no-op for unconfigured properties. + // + // The two path arguments are passed as null so each property is evaluated against the + // restrictions declared on its own declaring type. That resolves the attribute-based + // restrictions and the type/property-level model-bound configurations this walk targets; + // a restriction scoped to a specific traversal path is intentionally not resolved here, so + // the outcome stays consistent regardless of how the property was reached. + if (EdmHelpers.IsNotFilterable(property, null, null, model, enableFilter: true)) + { + throw new ODataException(Error.Format(SRResources.NotFilterablePropertyUsedInFilter, property.Name)); + } + + if (checkSelectable && EdmHelpers.IsNotSelectable(property, null, null, model, enableSelect: true)) + { + throw new ODataException(Error.Format(SRResources.NotSelectablePropertyUsedInSelect, property.Name)); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationController.cs new file mode 100644 index 000000000..c12b9576d --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationController.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.ApplyQueryValidation; + +public class ApplyValidationItemsController : ODataController +{ + [EnableQuery] + public ActionResult> Get() + { + return Ok(ApplyValidationDataSource.Items); + } +} + +// Same data, but the endpoint restricts the function and arithmetic-operator allow-lists. This proves +// end-to-end that those ODataValidationSettings limits are enforced for $apply (groupby/aggregate/ +// compute) and top-level $compute, not just for $filter. +public class RestrictedLimitItemsController : ODataController +{ + [EnableQuery( + AllowedFunctions = AllowedFunctions.AllFunctions & ~AllowedFunctions.Length, + AllowedArithmeticOperators = AllowedArithmeticOperators.All & ~AllowedArithmeticOperators.Multiply)] + public ActionResult> Get() + { + return Ok(ApplyValidationDataSource.Items); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationDataModel.cs new file mode 100644 index 000000000..278fa4d16 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationDataModel.cs @@ -0,0 +1,61 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.ApplyQueryValidation; + +/// +/// The entity used by the apply/compute query validation tests. It carries allowed properties +/// (, ), a not-filterable string and numeric property +/// (, ) and a property that is configured +/// as not selectable through model-bound query settings (). +/// +public class ApplyValidationItem +{ + public int Id { get; set; } + public string Name { get; set; } + public int Amount { get; set; } + + // Configured as not selectable via model-bound query settings in the EDM model. + public string NotSelectableName { get; set; } + + [NotFilterable] + public string RestrictedName { get; set; } + + [NotFilterable] + public int RestrictedAmount { get; set; } + + // A self-referencing collection so entity-set aggregates (aggregate(Related(... with ...))) + // can be exercised end-to-end. + public IList Related { get; set; } +} + +/// +/// A deterministic in-memory data source for . +/// +public static class ApplyValidationDataSource +{ + private static readonly List Data = BuildData(); + + private static List BuildData() + { + var item1 = new ApplyValidationItem { Id = 1, Name = "Alpha", Amount = 10, NotSelectableName = "S1", RestrictedName = "R1", RestrictedAmount = 100 }; + var item2 = new ApplyValidationItem { Id = 2, Name = "Beta", Amount = 20, NotSelectableName = "S2", RestrictedName = "R2", RestrictedAmount = 200 }; + var item3 = new ApplyValidationItem { Id = 3, Name = "Alpha", Amount = 30, NotSelectableName = "S3", RestrictedName = "R3", RestrictedAmount = 300 }; + + item1.Related = new List { item2, item3 }; + item2.Related = new List { item3 }; + item3.Related = new List { item1 }; + + return new List { item1, item2, item3 }; + } + + public static IQueryable Items => Data.AsQueryable(); +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationEdmModel.cs new file mode 100644 index 000000000..1e5ebb8af --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationEdmModel.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.ApplyQueryValidation; + +/// +/// Builds the EDM model for the apply/compute query validation tests and marks +/// as not selectable through the entity type's +/// model-bound query settings, mirroring how existing tests configure non-selectable properties. +/// +public static class ApplyQueryValidationEdmModel +{ + public static IEdmModel GetEdmModel() + { + var builder = new ODataConventionModelBuilder(); + builder.EntitySet("ApplyValidationItems"); + builder.EntitySet("RestrictedLimitItems"); + var model = builder.GetEdmModel(); + + var entityType = model.SchemaElements.OfType().Single(t => t.Name == nameof(ApplyValidationItem)); + var settings = new Microsoft.OData.ModelBuilder.Config.ModelBoundQuerySettings(); + settings.SelectConfigurations.Add(nameof(ApplyValidationItem.NotSelectableName), SelectExpandType.Disabled); + model.SetAnnotationValue(entityType, settings); + + return model; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationTests.cs new file mode 100644 index 000000000..a664b087a --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/ApplyQueryValidation/ApplyQueryValidationTests.cs @@ -0,0 +1,145 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OData.E2E.Tests.Extensions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData.Edm; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.ApplyQueryValidation; + +/// +/// End-to-end tests asserting that property restrictions are enforced consistently across +/// $filter, $apply (filter/groupby/aggregate/compute) and top-level $compute. +/// A property marked as not filterable or configured as not selectable is expected to be rejected +/// with regardless of which query option references it, while +/// the equivalent request against an allowed property is expected to succeed with +/// . +/// +public class ApplyQueryValidationTests : WebApiTestBase +{ + public ApplyQueryValidationTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + IEdmModel edmModel = ApplyQueryValidationEdmModel.GetEdmModel(); + + services.ConfigureControllers(typeof(ApplyValidationItemsController), typeof(RestrictedLimitItemsController)); + + services.AddControllers().AddOData(options => + options.EnableQueryFeatures().AddRouteComponents("odata", edmModel)); + } + + // Each case references a restricted property (not filterable, or not selectable) through a + // query option. The expected/consistent behavior is a 400 response, matching the equivalent + // $filter/$select. The $filter case is the regression anchor: it already returns 400 today. + [Theory] + [InlineData("$apply=filter(RestrictedName eq 'R1')")] + [InlineData("$apply=groupby((NotSelectableName))")] + [InlineData("$apply=aggregate(RestrictedAmount with max as MaxRestricted)")] + [InlineData("$apply=aggregate(Related(RestrictedAmount with sum as SumRestricted))")] + [InlineData("$apply=compute(RestrictedName eq 'R1' as Flag)")] + [InlineData("$compute=RestrictedName eq 'R1' as Flag")] + [InlineData("$filter=RestrictedName eq 'R1'")] + public async Task QueryRestrictedProperty_ThroughApplyFilterComputeOrSelect_ReturnsBadRequest(string query) + { + // Arrange + string queryUrl = $"odata/ApplyValidationItems?{query}"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + HttpClient client = CreateClient(); + + // Act + HttpResponseMessage response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + // Each case references an allowed property through the same query options. These already succeed + // today and must keep succeeding, proving the restriction is selective rather than all-or-nothing. + [Theory] + [InlineData("$apply=filter(Name eq 'Alpha')")] + [InlineData("$apply=groupby((Name))")] + [InlineData("$apply=aggregate(Amount with sum as TotalAmount)")] + [InlineData("$apply=aggregate(Related(Amount with sum as SumAmount))")] + [InlineData("$apply=aggregate($count as Total)")] + [InlineData("$apply=compute(Amount mul 2 as DoubleAmount)")] + [InlineData("$compute=Amount mul 2 as DoubleAmount")] + // A filter() that follows groupby/aggregate references the aggregation alias (a computed value, + // not a model property). The extended validation must not treat that alias as a restricted + // property and falsely reject an otherwise legitimate query. + [InlineData("$apply=groupby((Name),aggregate(Amount with sum as Total))/filter(Total gt 0)")] + public async Task QueryAllowedProperty_ThroughApplyFilterOrCompute_ReturnsOk(string query) + { + // Arrange + string queryUrl = $"odata/ApplyValidationItems?{query}"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + HttpClient client = CreateClient(); + + // Act + HttpResponseMessage response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + // The RestrictedLimitItems endpoint disallows the 'length' function and the 'mul' arithmetic + // operator. Each case uses an allowed property but a disallowed function/operator inside + // $apply (groupby/aggregate/compute) or top-level $compute, so the only possible cause of the + // 400 is the operator/function allow-list now being enforced for those transformations. + [Theory] + [InlineData("$compute=length(Name) as L")] + [InlineData("$apply=compute(length(Name) as L)")] + [InlineData("$compute=Amount mul 2 as D")] + [InlineData("$apply=compute(Amount mul 2 as D)")] + [InlineData("$apply=aggregate(Amount mul 2 with sum as D)")] + public async Task DisallowedFunctionOrOperator_ThroughApplyOrCompute_ReturnsBadRequest(string query) + { + // Arrange + string queryUrl = $"odata/RestrictedLimitItems?{query}"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + HttpClient client = CreateClient(); + + // Act + HttpResponseMessage response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + // The same endpoint still accepts functions/operators that remain allowed, proving the enforcement + // is selective (allow-list driven) rather than blocking every function/operator in $apply/$compute. + [Theory] + [InlineData("$compute=Name eq 'Alpha' as Flag")] + [InlineData("$apply=compute(Name eq 'Alpha' as Flag)")] + [InlineData("$apply=aggregate(Amount with sum as TotalAmount)")] + [InlineData("$apply=groupby((Name))")] + public async Task AllowedFunctionOrOperator_ThroughApplyOrCompute_ReturnsOk(string query) + { + // Arrange + string queryUrl = $"odata/RestrictedLimitItems?{query}"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + HttpClient client = CreateClient(); + + // Act + HttpResponseMessage response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl index e2126e090..4e7ceeb0d 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl +++ b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl @@ -1449,8 +1449,10 @@ public class Microsoft.AspNetCore.OData.Query.ApplyQueryOption { Microsoft.AspNetCore.OData.Query.ODataQueryContext Context { public get; } string RawValue { public get; } System.Type ResultClrType { public get; } + Microsoft.AspNetCore.OData.Query.Validator.IApplyQueryValidator Validator { public get; public set; } public System.Linq.IQueryable ApplyTo (System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) + public void Validate (Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } public class Microsoft.AspNetCore.OData.Query.ComputeQueryOption { @@ -3280,6 +3282,10 @@ public class Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder : M public virtual System.Linq.Expressions.Expression CreateTypeNameExpression (System.Linq.Expressions.Expression source, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmModel model) } +public interface Microsoft.AspNetCore.OData.Query.Validator.IApplyQueryValidator { + void Validate (Microsoft.AspNetCore.OData.Query.ApplyQueryOption applyQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) +} + public interface Microsoft.AspNetCore.OData.Query.Validator.IComputeQueryValidator { void Validate (Microsoft.AspNetCore.OData.Query.ComputeQueryOption computeQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } @@ -3331,6 +3337,12 @@ public abstract class Microsoft.AspNetCore.OData.Query.Validator.QueryValidatorC Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings ValidationSettings { public get; public set; } } +public class Microsoft.AspNetCore.OData.Query.Validator.ApplyQueryValidator : IApplyQueryValidator { + public ApplyQueryValidator () + + public virtual void Validate (Microsoft.AspNetCore.OData.Query.ApplyQueryOption applyQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) +} + public class Microsoft.AspNetCore.OData.Query.Validator.ComputeQueryValidator : IComputeQueryValidator { public ComputeQueryValidator () diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs index 74e74c4e8..7f5812795 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Linq.Expressions; using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Validator; using Microsoft.AspNetCore.OData.Query.Wrapper; using Microsoft.AspNetCore.OData.Routing.Controllers; using Microsoft.AspNetCore.OData.TestCommon; @@ -70,6 +71,106 @@ public void ApplyToApplyQueryOption_ThrowsArgumentNull_ForInputParameter() "$apply query options not supported for LINQ to SQL providers."); } + // --------------------------------------------------------------------- + // $apply validator wiring: the internal test constructor, the Validator + // property and Validate(ODataValidationSettings). + // --------------------------------------------------------------------- + + [Fact] + public void CtorApplyQueryOption_ThrowsArgumentNull_ForInternalTestConstructor() + { + // Arrange + var context = new ODataQueryContext(_model, typeof(ApplyCustomer)); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNullOrEmpty(() => new ApplyQueryOption(null, context), "rawValue"); + ExceptionAssert.ThrowsArgumentNullOrEmpty(() => new ApplyQueryOption(string.Empty, context), "rawValue"); + ExceptionAssert.ThrowsArgumentNull(() => new ApplyQueryOption("groupby((Name))", null), "context"); + } + + [Fact] + public void ApplyQueryOption_InternalTestConstructor_ParsesApplyClause() + { + // Arrange + var context = new ODataQueryContext(_model, typeof(ApplyCustomer)); + + // Act - the test constructor builds a parser so ApplyClause is available. + var apply = new ApplyQueryOption("groupby((Name))", context); + + // Assert + Assert.Equal("groupby((Name))", apply.RawValue); + Assert.NotNull(apply.ApplyClause); + Assert.Single(apply.ApplyClause.Transformations); + } + + [Fact] + public void ApplyQueryOption_Validator_IsNonNullApplyQueryValidator_ByDefault() + { + // Arrange + var context = new ODataQueryContext(_model, typeof(ApplyCustomer)); + + // Act + var apply = new ApplyQueryOption("groupby((Name))", context); + + // Assert + Assert.NotNull(apply.Validator); + Assert.IsType(apply.Validator); + } + + [Fact] + public void ApplyQueryOption_Validator_IsSettable() + { + // Arrange + var context = new ODataQueryContext(_model, typeof(ApplyCustomer)); + var apply = new ApplyQueryOption("groupby((Name))", context); + var validator = new Mock().Object; + + // Act + apply.Validator = validator; + + // Assert + Assert.Same(validator, apply.Validator); + } + + [Fact] + public void ValidateApplyQueryOption_ThrowsArgumentNull_ForNullValidationSettings() + { + // Arrange + var context = new ODataQueryContext(_model, typeof(ApplyCustomer)); + var apply = new ApplyQueryOption("groupby((Name))", context); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNull(() => apply.Validate(null), "validationSettings"); + } + + [Fact] + public void ValidateApplyQueryOption_InvokesValidator() + { + // Arrange + var context = new ODataQueryContext(_model, typeof(ApplyCustomer)); + var apply = new ApplyQueryOption("groupby((Name))", context); + var settings = new ODataValidationSettings(); + var validator = new Mock(); + apply.Validator = validator.Object; + + // Act + apply.Validate(settings); + + // Assert + validator.Verify(v => v.Validate(apply, settings), Times.Once); + } + + [Fact] + public void ValidateApplyQueryOption_DoesNotThrow_WhenValidatorIsNull() + { + // Arrange + var context = new ODataQueryContext(_model, typeof(ApplyCustomer)); + var apply = new ApplyQueryOption("groupby((Name))", context) { Validator = null }; + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => apply.Validate(new ODataValidationSettings())); + } + // Legal apply queries usable against CustomerApplyTestData. // Tuple is: apply, expected number public static TheoryDataSet>> CustomerTestApplies diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ApplyQueryValidatorTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ApplyQueryValidatorTests.cs new file mode 100644 index 000000000..6c065c4c4 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ApplyQueryValidatorTests.cs @@ -0,0 +1,441 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Validator; +using Microsoft.AspNetCore.OData.Tests.Commons; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using Microsoft.OData.ModelBuilder.Config; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Tests.Query.Validator; + +/// +/// Unit tests for . +/// The filter transformation is validated with the same rules as $filter (full parity, +/// tied to the model's switch). The +/// groupby, aggregate and compute transformations are selective: they reject only +/// properties that the model explicitly restricts (a not-filterable attribute or a not-selectable +/// model-bound configuration), so a property that is not explicitly restricted keeps validating +/// regardless of the global enable switches. +/// +public class ApplyQueryValidatorTests +{ + private readonly ApplyQueryValidator _validator = new ApplyQueryValidator(); + + private const string NotFilterableMessage = + "The property 'NotFilterableProperty' cannot be used in the $filter query option."; + + #region Argument validation + + [Fact] + public void Validate_ThrowsArgumentNull_ForNullApplyQueryOption() + { + // Arrange & Act & Assert + ExceptionAssert.ThrowsArgumentNull( + () => _validator.Validate(null, new ODataValidationSettings()), "applyQueryOption"); + } + + [Fact] + public void Validate_ThrowsArgumentNull_ForNullValidationSettings() + { + // Arrange + var apply = new ApplyQueryOption("groupby((Name))", CreateContext()); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNull(() => _validator.Validate(apply, null), "validationSettings"); + } + + #endregion + + #region groupby / aggregate / compute reject explicitly restricted (not-filterable) properties + + [Theory] + [InlineData("groupby((NotFilterableProperty))")] + [InlineData("groupby((NonFilterableProperty))")] + [InlineData("groupby((Id,NotFilterableProperty))")] + [InlineData("groupby((Address/NotFilterableProperty))")] + public void Validate_Throws_ForRestrictedPropertyInGroupBy(string apply) + { + // Arrange + var option = new ApplyQueryOption(apply, CreateContext()); + + // Act & Assert + ExceptionAssert.Throws(() => _validator.Validate(option, new ODataValidationSettings())); + } + + [Theory] + [InlineData("aggregate(NotFilterableProperty with countdistinct as R)")] + [InlineData("aggregate(NonFilterableProperty with countdistinct as R)")] + [InlineData("groupby((Id),aggregate(NotFilterableProperty with countdistinct as R))")] + public void Validate_Throws_ForRestrictedPropertyInAggregate(string apply) + { + // Arrange + var option = new ApplyQueryOption(apply, CreateContext()); + + // Act & Assert - both [NotFilterable] and [NonFilterable] properties are rejected. + ExceptionAssert.Throws(() => _validator.Validate(option, new ODataValidationSettings())); + } + + [Theory] + [InlineData("compute(NotFilterableProperty eq 'x' as F)")] + [InlineData("compute(length(NotFilterableProperty) as L)")] + [InlineData("compute(Name eq 'a' or NotFilterableProperty eq 'b' as F)")] + [InlineData("compute(Address/NotFilterableProperty eq 'x' as F)")] + public void Validate_Throws_ForRestrictedPropertyInCompute(string apply) + { + // Arrange + var option = new ApplyQueryOption(apply, CreateContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), NotFilterableMessage); + } + + [Fact] + public void Validate_Throws_ForRestrictedPropertyInEntitySetAggregate() + { + // Arrange - aggregate over a navigation collection where the inner property is restricted. + var option = new ApplyQueryOption("aggregate(Contacts(NotFilterableProperty with countdistinct as R))", CreateContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), NotFilterableMessage); + } + + [Fact] + public void Validate_Throws_ForRestrictedPropertyInsideAnyLambdaBody() + { + // Arrange - the restricted property is referenced only inside the any(...) lambda body. + var option = new ApplyQueryOption("compute(Contacts/any(c: c/NotFilterableProperty ne null) as HasRestricted)", CreateContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), NotFilterableMessage); + } + + [Fact] + public void Validate_Throws_ForRestrictedPropertyInsideAllLambdaBody() + { + // Arrange - the restricted property is referenced only inside the all(...) lambda body; this + // guards the all(...) body being walked the same way as the any(...) body. + var option = new ApplyQueryOption("compute(Contacts/all(c: c/NotFilterableProperty ne null) as AllRestricted)", CreateContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), NotFilterableMessage); + } + + [Fact] + public void Validate_Throws_ForRestrictedPropertyThroughSingleNavigation() + { + // Arrange - restricted property reached through a single-valued navigation. + var option = new ApplyQueryOption("compute(RelationshipManager/NotFilterableProperty eq 'x' as F)", CreateContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), NotFilterableMessage); + } + + #endregion + + #region groupby / aggregate / compute are a no-op for unrestricted properties (default, unconfigured context) + + // The context leaves EnableFilter/EnableSelect at their framework defaults (false). These cases + // prove the groupby/aggregate/compute walk is selective: an unrestricted property keeps validating + // without error even though nothing enabled filter or select globally. + [Theory] + [InlineData("groupby((Name))")] + [InlineData("groupby((Id))")] + [InlineData("groupby((Name,Id))")] + [InlineData("groupby((Address/City))")] + [InlineData("groupby((Name),aggregate(AmountSpent with sum as Total))")] + [InlineData("aggregate(AmountSpent with sum as Total)")] + [InlineData("aggregate($count as Count)")] + [InlineData("aggregate(Contacts(Name with countdistinct as R))")] + [InlineData("compute(AmountSpent mul 2 as Double)")] + [InlineData("compute(length(Name) as L)")] + [InlineData("compute(Contacts/any(c: c/Name ne null) as HasNamed)")] + [InlineData("compute(Contacts/all(c: c/Name ne null) as AllNamed)")] + [InlineData("compute(RelationshipManager/Name eq 'x' as F)")] + public void Validate_DoesNotThrow_ForUnrestrictedPropertyInGroupByAggregateCompute(string apply) + { + // Arrange + var option = new ApplyQueryOption(apply, CreateContext()); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => _validator.Validate(option, new ODataValidationSettings())); + } + + #endregion + + #region filter transformation keeps full $filter parity + + [Fact] + public void Validate_Throws_ForRestrictedPropertyInFilterTransformation() + { + // Arrange - filter is validated as $filter, so the restricted property is rejected. + var option = new ApplyQueryOption("filter(NotFilterableProperty eq 'x')", CreateFilterEnabledContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), NotFilterableMessage); + } + + [Fact] + public void Validate_DoesNotThrow_ForUnrestrictedPropertyInFilterTransformation_WhenFilterEnabled() + { + // Arrange - when the model enables filter, an unrestricted property validates through filter(). + var option = new ApplyQueryOption("filter(Name eq 'x')", CreateFilterEnabledContext()); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => _validator.Validate(option, new ODataValidationSettings())); + } + + [Fact] + public void Validate_Throws_ForFilterTransformation_MirrorsDollarFilterWhenFilterNotEnabled() + { + // Arrange - the same unrestricted property inside filter() is rejected when the model does not + // enable filter, exactly as $filter=Name would be. This documents that filter() follows $filter + // parity while groupby((Name)) on the identical context is a no-op (see the no-op theory above). + var context = CreateContext(); + var filterOption = new ApplyQueryOption("filter(Name eq 'x')", context); + var groupByOption = new ApplyQueryOption("groupby((Name))", context); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(filterOption, new ODataValidationSettings()), + "The property 'Name' cannot be used in the $filter query option."); + ExceptionAssert.DoesNotThrow(() => _validator.Validate(groupByOption, new ODataValidationSettings())); + } + + #endregion + + #region Chained transformations + + [Fact] + public void Validate_Throws_WhenRestrictedPropertyAppearsInLaterTransformation() + { + // Arrange - the first transformation is allowed; the restricted property is in the second. + var option = new ApplyQueryOption("filter(Name eq 'x')/groupby((NotFilterableProperty))", CreateFilterEnabledContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), NotFilterableMessage); + } + + #endregion + + #region groupby / aggregate / compute enforce operator, function and node-count limits + + // These cases prove that the operator / function allow-lists and the node-count limit from + // ODataValidationSettings are enforced for the groupby/aggregate/compute transformations, not just + // for filter(). The referenced properties (Name/AmountSpent) are unrestricted, so only the + // operator/function/complexity limit can be the cause of the rejection. + + [Fact] + public void Validate_Throws_ForDisallowedFunctionInCompute() + { + // Arrange - length(...) is used but AllowedFunctions excludes Length. + var option = new ApplyQueryOption("compute(length(Name) as L)", CreateContext()); + var settings = new ODataValidationSettings { AllowedFunctions = AllowedFunctions.AllFunctions & ~AllowedFunctions.Length }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, settings), + "Function 'length' is not allowed. To allow it, set the 'AllowedFunctions' property on EnableQueryAttribute or QueryValidationSettings."); + } + + [Fact] + public void Validate_Throws_ForDisallowedArithmeticOperatorInCompute() + { + // Arrange - 'mul' is used but AllowedArithmeticOperators excludes Multiply. + var option = new ApplyQueryOption("compute(AmountSpent mul 2 as Double)", CreateContext()); + var settings = new ODataValidationSettings { AllowedArithmeticOperators = AllowedArithmeticOperators.All & ~AllowedArithmeticOperators.Multiply }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, settings), + "Arithmetic operator 'Multiply' is not allowed. To allow it, set the 'AllowedArithmeticOperators' property on EnableQueryAttribute or QueryValidationSettings."); + } + + [Fact] + public void Validate_Throws_ForDisallowedLogicalOperatorInCompute() + { + // Arrange - 'eq' is used but AllowedLogicalOperators excludes Equal. + var option = new ApplyQueryOption("compute(Name eq 'x' as Flag)", CreateContext()); + var settings = new ODataValidationSettings { AllowedLogicalOperators = AllowedLogicalOperators.All & ~AllowedLogicalOperators.Equal }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, settings), + "Logical operator 'Equal' is not allowed. To allow it, set the 'AllowedLogicalOperators' property on EnableQueryAttribute or QueryValidationSettings."); + } + + [Fact] + public void Validate_Throws_ForDisallowedArithmeticOperatorInAggregate() + { + // Arrange - the aggregate expression itself contains 'mul', which is disallowed. + var option = new ApplyQueryOption("aggregate(AmountSpent mul 2 with sum as Total)", CreateContext()); + var settings = new ODataValidationSettings { AllowedArithmeticOperators = AllowedArithmeticOperators.All & ~AllowedArithmeticOperators.Multiply }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, settings), + "Arithmetic operator 'Multiply' is not allowed. To allow it, set the 'AllowedArithmeticOperators' property on EnableQueryAttribute or QueryValidationSettings."); + } + + [Fact] + public void Validate_Throws_WhenNodeCountExceededInCompute() + { + // Arrange - a small MaxNodeCount is exceeded by the compute expression tree. + var option = new ApplyQueryOption("compute(AmountSpent mul 2 as Double)", CreateContext()); + var settings = new ODataValidationSettings { MaxNodeCount = 1 }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, settings), + "The node count limit of '1' has been exceeded. To increase the limit, set the 'MaxNodeCount' property on EnableQueryAttribute or ODataValidationSettings."); + } + + [Theory] + [InlineData("compute(length(Name) as L)")] + [InlineData("compute(AmountSpent mul 2 as Double)")] + [InlineData("compute(Name eq 'x' as Flag)")] + [InlineData("aggregate(AmountSpent mul 2 with sum as Total)")] + public void Validate_DoesNotThrow_ForOperatorFunctionOrNodeCount_WhenLimitsPermit(string apply) + { + // Arrange - default settings allow every function/operator and set MaxNodeCount to 100, so the + // same expressions that the restrictive settings above reject validate cleanly here. This guards + // against the new enforcement producing false rejections under permissive limits. + var option = new ApplyQueryOption(apply, CreateContext()); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => _validator.Validate(option, new ODataValidationSettings())); + } + + #endregion + + #region Not-selectable (model-bound) properties in groupby + + [Fact] + public void Validate_Throws_ForNotSelectablePropertyInGroupBy() + { + // Arrange + var option = new ApplyQueryOption("groupby((RestrictedName))", CreateNotSelectableContext()); + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(option, new ODataValidationSettings()), + "The property 'RestrictedName' cannot be used in the $select query option."); + } + + [Fact] + public void Validate_DoesNotThrow_ForSelectablePropertyInGroupBy() + { + // Arrange + var option = new ApplyQueryOption("groupby((AllowedName))", CreateNotSelectableContext()); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => _validator.Validate(option, new ODataValidationSettings())); + } + + #endregion + + #region Virtual Validate can be overridden by a derived validator + + [Fact] + public void Validate_IsInvoked_OnDerivedValidator_AndBaseEnforcementStillRuns() + { + // Arrange + var validator = new TrackingApplyQueryValidator(); + var option = new ApplyQueryOption("groupby((NotFilterableProperty))", CreateContext()); + + // Act & Assert - the override runs and the base enforcement is still applied. + ExceptionAssert.Throws(() => validator.Validate(option, new ODataValidationSettings())); + Assert.Equal(1, validator.ValidateCallCount); + } + + [Fact] + public void Validate_CanBeFullyReplaced_ByDerivedValidator() + { + // Arrange - a derived validator that does not call base replaces the enforcement entirely. + var validator = new NoOpApplyQueryValidator(); + var option = new ApplyQueryOption("groupby((NotFilterableProperty))", CreateContext()); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => validator.Validate(option, new ODataValidationSettings())); + } + + #endregion + + #region Helpers + + // Context over QueryCompositionCustomer, which carries the [NotFilterable]/[NonFilterable] + // properties and unrestricted Name/Id/AmountSpent. EnableFilter/EnableSelect are left at their + // framework defaults (false), so the no-op cases prove the walk does not depend on those switches. + private static ODataQueryContext CreateContext() + { + return ValidationTestHelper.CreateCustomerContext(); + } + + // Same model, but with filtering enabled so the filter() parity cases exercise an enabled model. + private static ODataQueryContext CreateFilterEnabledContext() + { + var context = ValidationTestHelper.CreateCustomerContext(); + context.DefaultQueryConfigurations.EnableFilter = true; + context.DefaultQueryConfigurations.EnableSelect = true; + return context; + } + + // Context where RestrictedName is configured as not selectable via model-bound query settings. + // EnableSelect is left false to show the model-bound configuration is honored on its own. + private static ODataQueryContext CreateNotSelectableContext() + { + var builder = new ODataConventionModelBuilder(); + builder.EntitySet("ApplyRestrictionModels"); + var model = builder.GetEdmModel(); + + var entityType = model.SchemaElements.OfType().Single(t => t.Name == nameof(ApplyRestrictionModel)); + var settings = new ModelBoundQuerySettings(); + settings.SelectConfigurations.Add(nameof(ApplyRestrictionModel.RestrictedName), SelectExpandType.Disabled); + model.SetAnnotationValue(entityType, settings); + + return new ODataQueryContext(model, typeof(ApplyRestrictionModel), null); + } + + private class ApplyRestrictionModel + { + public int Id { get; set; } + public string AllowedName { get; set; } + public string RestrictedName { get; set; } + } + + private sealed class TrackingApplyQueryValidator : ApplyQueryValidator + { + public int ValidateCallCount { get; private set; } + + public override void Validate(ApplyQueryOption applyQueryOption, ODataValidationSettings validationSettings) + { + ValidateCallCount++; + base.Validate(applyQueryOption, validationSettings); + } + } + + private sealed class NoOpApplyQueryValidator : ApplyQueryValidator + { + public override void Validate(ApplyQueryOption applyQueryOption, ODataValidationSettings validationSettings) + { + // Intentionally does not call base: a derived validator can fully replace the behavior. + } + } + + #endregion +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ComputeQueryValidatorTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ComputeQueryValidatorTests.cs index 426dc9647..5f276ab33 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ComputeQueryValidatorTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ComputeQueryValidatorTests.cs @@ -55,4 +55,88 @@ public void ValidateComputeQueryValidator_ThrowsIfUnknownPropertyInComputeClause new ODataValidationSettings()), "Could not find a property named 'test' on type 'Microsoft.AspNetCore.OData.Tests.Query.Models.QueryCompositionCustomer'."); } + + [Theory] + [InlineData("NotFilterableProperty eq 'x' as Flag")] + [InlineData("length(NotFilterableProperty) as Length")] + [InlineData("Name eq 'a' or NotFilterableProperty eq 'b' as Flag")] + [InlineData("Address/NotFilterableProperty eq 'x' as Flag")] + [InlineData("Contacts/all(c: c/NotFilterableProperty ne null) as AllRestricted")] + [InlineData("Contacts/any(c: c/NotFilterableProperty ne null) as HasRestricted")] + public void ValidateComputeQueryValidator_ThrowsForRestrictedPropertyInComputeClause(string compute) + { + // Arrange & Act & Assert - a not-filterable property referenced through $compute is rejected + // the same way it is through $filter, regardless of the global enable switches. + ExceptionAssert.Throws(() => + _validator.Validate( + new ComputeQueryOption(compute, _context), + new ODataValidationSettings()), + "The property 'NotFilterableProperty' cannot be used in the $filter query option."); + } + + [Theory] + [InlineData("Name eq 'a' as Flag")] + [InlineData("AmountSpent mul 2 as Double")] + [InlineData("length(Name) as Length")] + [InlineData("Address/City eq 'a' as Flag")] + [InlineData("Contacts/any(c: c/Name ne null) as HasNamed")] + public void ValidateComputeQueryValidator_DoesNotThrowForUnrestrictedPropertyInComputeClause(string compute) + { + // Arrange & Act & Assert - an unrestricted property keeps validating even though the context + // leaves EnableFilter/EnableSelect at their framework defaults (false). + ExceptionAssert.DoesNotThrow(() => + _validator.Validate( + new ComputeQueryOption(compute, _context), + new ODataValidationSettings())); + } + + [Fact] + public void ValidateComputeQueryValidator_ThrowsForDisallowedFunction() + { + // Arrange - length(...) is used but AllowedFunctions excludes Length. The referenced property + // (Name) is unrestricted, so only the function allow-list can cause the rejection. This closes + // the $compute= bypass. + var settings = new ODataValidationSettings { AllowedFunctions = AllowedFunctions.AllFunctions & ~AllowedFunctions.Length }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(new ComputeQueryOption("length(Name) as L", _context), settings), + "Function 'length' is not allowed. To allow it, set the 'AllowedFunctions' property on EnableQueryAttribute or QueryValidationSettings."); + } + + [Fact] + public void ValidateComputeQueryValidator_ThrowsForDisallowedArithmeticOperator() + { + // Arrange - 'mul' is used but AllowedArithmeticOperators excludes Multiply. + var settings = new ODataValidationSettings { AllowedArithmeticOperators = AllowedArithmeticOperators.All & ~AllowedArithmeticOperators.Multiply }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(new ComputeQueryOption("AmountSpent mul 2 as D", _context), settings), + "Arithmetic operator 'Multiply' is not allowed. To allow it, set the 'AllowedArithmeticOperators' property on EnableQueryAttribute or QueryValidationSettings."); + } + + [Fact] + public void ValidateComputeQueryValidator_ThrowsForDisallowedLogicalOperator() + { + // Arrange - 'eq' is used but AllowedLogicalOperators excludes Equal. + var settings = new ODataValidationSettings { AllowedLogicalOperators = AllowedLogicalOperators.All & ~AllowedLogicalOperators.Equal }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(new ComputeQueryOption("Name eq 'x' as Flag", _context), settings), + "Logical operator 'Equal' is not allowed. To allow it, set the 'AllowedLogicalOperators' property on EnableQueryAttribute or QueryValidationSettings."); + } + + [Fact] + public void ValidateComputeQueryValidator_ThrowsWhenNodeCountExceeded() + { + // Arrange - a small MaxNodeCount is exceeded by the compute expression tree. + var settings = new ODataValidationSettings { MaxNodeCount = 1 }; + + // Act & Assert + ExceptionAssert.Throws( + () => _validator.Validate(new ComputeQueryOption("AmountSpent mul 2 as D", _context), settings), + "The node count limit of '1' has been exceeded. To increase the limit, set the 'MaxNodeCount' property on EnableQueryAttribute or ODataValidationSettings."); + } } diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ODataQueryValidatorApplyTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ODataQueryValidatorApplyTest.cs new file mode 100644 index 000000000..d0ab054fa --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ODataQueryValidatorApplyTest.cs @@ -0,0 +1,270 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Validator; +using Microsoft.AspNetCore.OData.Tests.Commons; +using Microsoft.AspNetCore.OData.Tests.Extensions; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using Microsoft.OData.ModelBuilder.Config; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Tests.Query.Validator; + +/// +/// Tests asserting that per-clause property restrictions are enforced consistently for the +/// $apply and top-level $compute query options, matching the enforcement that +/// already exists for $filter (not-filterable properties) and $select +/// (not-selectable properties). +/// A property a service marks as not filterable or configures as not selectable is expected to +/// be rejected with an when it is referenced through +/// $apply=filter(...), $apply=groupby((...)), $apply=aggregate(... with ...), +/// $apply=compute(...) or top-level $compute=..., exactly as it is when referenced +/// through the equivalent $filter or $select. +/// +public class ODataQueryValidatorApplyTest +{ + // --------------------------------------------------------------------- + // Not-filterable properties referenced through $apply / $compute. + // These describe the expected (consistent) behavior; they are rejected + // through $filter today and are expected to be rejected the same way here. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("$apply=filter(NotFilterableProperty eq 'restricted')")] + [InlineData("$apply=filter(NonFilterableProperty eq 'restricted')")] + public void Validate_Throws_ForNotFilterablePropertyInApplyFilter(string queryString) + { + // Arrange + var options = CreateCustomerQueryOptions(queryString); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Theory] + [InlineData("$apply=filter(NotFilterableNavigationProperty/Name eq 'restricted')")] + [InlineData("$apply=filter(NonFilterableNavigationProperty/Name eq 'restricted')")] + public void Validate_Throws_ForNotFilterableNavigationPropertyInApplyFilter(string queryString) + { + // Arrange + var options = CreateCustomerQueryOptions(queryString); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_Throws_ForNotFilterablePropertyInApplyAggregate() + { + // Arrange + var options = CreateCustomerQueryOptions("$apply=aggregate(NotFilterableProperty with countdistinct as RestrictedCount)"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_Throws_ForNotFilterablePropertyInNestedGroupByAggregate() + { + // Arrange + var options = CreateCustomerQueryOptions("$apply=groupby((Id),aggregate(NotFilterableProperty with countdistinct as RestrictedCount))"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_Throws_ForNotFilterablePropertyInApplyCompute() + { + // Arrange + var options = CreateCustomerQueryOptions("$apply=compute(NotFilterableProperty eq 'restricted' as RestrictedFlag)"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_Throws_ForNotFilterablePropertyInTopLevelCompute() + { + // Arrange + var options = CreateCustomerQueryOptions("$compute=NotFilterableProperty eq 'restricted' as RestrictedFlag"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + // --------------------------------------------------------------------- + // Allowed properties referenced through $apply / $compute must keep + // validating without error. This proves the restriction is selective + // (per-property) rather than rejecting every $apply / $compute. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("$apply=filter(Name eq 'allowed')")] + [InlineData("$apply=groupby((Name))")] + [InlineData("$apply=aggregate(AmountSpent with sum as TotalAmount)")] + [InlineData("$apply=compute(AmountSpent mul 2 as DoubleAmount)")] + [InlineData("$compute=AmountSpent mul 2 as DoubleAmount")] + public void Validate_DoesNotThrow_ForAllowedPropertyInApplyOrCompute(string queryString) + { + // Arrange + var options = CreateCustomerQueryOptions(queryString); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_DoesNotThrow_WhenNoApplyOrComputeIsPresent() + { + // Arrange - a request that carries neither $apply nor $compute. + var options = CreateCustomerQueryOptions("$filter=Name eq 'allowed'"); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => options.Validate(new ODataValidationSettings())); + } + + // --------------------------------------------------------------------- + // Regression anchor: the not-filterable property is already rejected + // through $filter today. The $apply / $compute tests above express the + // same expectation for the aggregation pipeline. + // --------------------------------------------------------------------- + + [Fact] + public void Validate_Throws_ForNotFilterablePropertyInFilter() + { + // Arrange + var options = CreateCustomerQueryOptions("$filter=NotFilterableProperty eq 'restricted'"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + // --------------------------------------------------------------------- + // Not-selectable properties referenced through $apply=groupby(...). + // A property configured as not selectable is rejected through $select + // today and is expected to be rejected the same way through groupby. + // --------------------------------------------------------------------- + + [Fact] + public void Validate_Throws_ForNotSelectablePropertyInApplyGroupBy() + { + // Arrange + var options = CreateNotSelectableQueryOptions("$apply=groupby((RestrictedName))"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_Throws_ForNotSelectablePropertyInLaterTransformation() + { + // Arrange - the second transformation references the not-selectable property. + var options = CreateNotSelectableQueryOptions("$apply=filter(AllowedName eq 'allowed')/groupby((RestrictedName))"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_DoesNotThrow_ForSelectablePropertyInApplyGroupBy() + { + // Arrange + var options = CreateNotSelectableQueryOptions("$apply=groupby((AllowedName))"); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => options.Validate(new ODataValidationSettings())); + } + + // --------------------------------------------------------------------- + // Regression anchor: the not-selectable property is already rejected + // through $select today, and the selectable property is accepted. This + // confirms the model configuration used by the groupby tests above. + // --------------------------------------------------------------------- + + [Fact] + public void Validate_Throws_ForNotSelectablePropertyInSelect() + { + // Arrange + var options = CreateNotSelectableQueryOptions("$select=RestrictedName"); + + // Act & Assert + ExceptionAssert.Throws(() => options.Validate(new ODataValidationSettings())); + } + + [Fact] + public void Validate_DoesNotThrow_ForSelectablePropertyInSelect() + { + // Arrange + var options = CreateNotSelectableQueryOptions("$select=AllowedName"); + + // Act & Assert + ExceptionAssert.DoesNotThrow(() => options.Validate(new ODataValidationSettings())); + } + + // --------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------- + + // Builds options over QueryCompositionCustomer, which carries the + // [NotFilterable]/[NonFilterable] properties and allowed Name/AmountSpent. + private static ODataQueryOptions CreateCustomerQueryOptions(string queryString) + { + // CreateCustomerContext(false) intentionally leaves RequestContainer unset; the + // ODataQueryOptions constructor requires a null RequestContainer and fills it from the request. + var context = ValidationTestHelper.CreateCustomerContext(false); + context.DefaultQueryConfigurations.EnableFilter = true; + context.DefaultQueryConfigurations.EnableSelect = true; + context.DefaultQueryConfigurations.MaxTop = null; + + return CreateQueryOptions(context, queryString); + } + + // Builds options over a model where RestrictedName is configured as not selectable. + private static ODataQueryOptions CreateNotSelectableQueryOptions(string queryString) + { + return CreateQueryOptions(CreateNotSelectableContext(), queryString); + } + + private static ODataQueryOptions CreateQueryOptions(ODataQueryContext context, string queryString) + { + var request = RequestFactory.Create("Get", "http://localhost/?" + queryString, setupAction: null); + return new ODataQueryOptions(context, request); + } + + // Configures RestrictedName as not selectable using model-bound query settings on the + // entity type. Top-level $select / groupby restriction is read from the entity type's + // SelectConfigurations (see EdmHelpers.IsNotSelectable), so the setting is attached there. + private static ODataQueryContext CreateNotSelectableContext() + { + var builder = new ODataConventionModelBuilder(); + builder.EntitySet("ApplyValidationModels"); + var model = builder.GetEdmModel(); + + var entityType = model.SchemaElements.OfType().Single(t => t.Name == nameof(ApplyValidationModel)); + var settings = new ModelBoundQuerySettings(); + settings.SelectConfigurations.Add(nameof(ApplyValidationModel.RestrictedName), SelectExpandType.Disabled); + model.SetAnnotationValue(entityType, settings); + + var context = new ODataQueryContext(model, typeof(ApplyValidationModel), null); + context.DefaultQueryConfigurations.EnableSelect = true; + context.DefaultQueryConfigurations.EnableFilter = true; + context.DefaultQueryConfigurations.MaxTop = null; + return context; + } + + private class ApplyValidationModel + { + public int Id { get; set; } + public string AllowedName { get; set; } + public string RestrictedName { get; set; } + } +}