diff --git a/analyzers/rspec/cs/S6932.html b/analyzers/rspec/cs/S6932.html new file mode 100644 index 00000000000..7f0cef39296 --- /dev/null +++ b/analyzers/rspec/cs/S6932.html @@ -0,0 +1,336 @@ +

The HttpRequest class provides access to the raw request data through the QueryString, Headers, and +Forms properties. However, whenever possible it is recommended to use model binding instead of directly accessing the input data.

+

Why is this an issue?

+

Both ASP.Net MVC implementations - Core and Framework - support model binding in a comparable fashion. Model binding streamlines the +process by automatically aligning data from HTTP requests with action method parameters, providing numerous benefits compared to manually parsing raw +incoming request data:

+
+
+ Simplicity +
+
+

Model binding simplifies the code by automatically mapping data from HTTP requests to action method parameters. You don’t need to write any + code to manually extract values from the request.

+
+
+ Type Safety +
+
+

Model binding provides type safety by automatically converting the incoming data into the appropriate .NET types. If the conversion fails, the + model state becomes invalid, which you can easily check using ModelState.IsValid.

+
+
+ Validation +
+
+

With model binding, you can easily apply validation rules to your models using data annotations. If the incoming data doesn’t comply with these + rules, the model state becomes invalid.

+
+
+ Security +
+
+

Model binding helps protect against over-posting attacks by only including properties in the model that you explicitly bind using the + [Bind] attribute or by using view models that only contain the properties you want to update.

+
+
+ Maintainability +
+
+

By using model binding, your code becomes cleaner, easier to read, and maintain. It promotes the use of strongly typed views, which can provide + compile-time checking of your views.

+
+
+

How to fix it in ASP.NET Core

+

Request.Form, Request.Form.Files, Request.Headers, Request.Query and Request.RouteValues are keyed +collections that expose data from the incoming HTTP request:

+ +

Model binding can bind these keyed collections to

+ +

To replace the keyed collection access, you can:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Replacewith parameter bindingor complex type bindingor route binding

Request.Form["id"]

optional [FromForm] + attribute on the parameter or a FormCollection parameter

optional [FromForm] + attribute on the property

Request.Form.Files

IFormFile, IFormFileCollection, or + IEnumerable<IFormFile> parameter

Request.Headers["id"]

[FromHeader] + attribute on the parameter

[FromHeader] + attribute on the property

Request.Query["id"]

optional [FromQuery] + attribute on the parameter

optional [FromQuery] + attribute on the property

Request.RouteValues["id"]

optional [FromRoute] + attribute on the parameter

optional [Route("{id}")]attribute on the + action method/controller or via conventional routing

+

The Model Binding in ASP.NET Core article describes the +mechanisms, conventions, and customization options for model binding in more detail. Route-based binding is described in the Routing to controller actions in ASP.NET Core document.

+

Code examples

+

Noncompliant code example

+
+public IActionResult Post()
+{
+    var name = Request.Form["name"];                           // Noncompliant: Request.Form
+    var birthdate = DateTime.Parse(Request.Form["Birthdate"]); // Noncompliant: Request.Form
+
+    var origin = Request.Headers[HeaderNames.Origin];          // Noncompliant: Request.Headers
+    var locale = Request.Query.TryGetValue("locale", out var locales)
+        ? locales.ToString()
+        : "en-US";                                             // Noncompliant: Request.Query
+    // ..
+}
+
+

Compliant solution

+
+public record User
+{
+    [Required, StringLength(100)]
+    public required string Name { get; init; }
+    [DataType(DataType.Date)]
+    public DateTime? Birthdate { get; init; }
+}
+
+public IActionResult Post(User user, [FromHeader] string origin, [FromQuery] string locale = "en-US")
+{
+    if (ModelState.IsValid)
+    {
+        // ...
+    }
+}
+
+

How does this work?

+

Model binding in ASP.NET Core MVC and ASP.NET MVC 4.x works by automatically mapping data from HTTP requests to action method parameters. Here’s a +step-by-step breakdown of how it works:

+
    +
  1. Request Data When a user submits a form or sends a request to an ASP.NET application, the request data might include form + data, query string parameters, request body, and HTTP headers.
  2. +
  3. Model Binder The model binder’s job is to create .NET objects from the request data. It looks at each parameter in the action + method and attempts to populate it with the incoming data.
  4. +
  5. Value Providers The model binder uses Value Providers to get data from various parts of the request, such as the query string, + form data, or route data. Each value provider tells the model binder where to find values in the request.
  6. +
  7. Binding The model binder tries to match the keys from the incoming data with the properties of the action method’s parameters. + If a match is found, it attempts to convert the incoming data into the appropriate .NET type and assigns it to the parameter.
  8. +
  9. Validation If the model binder can’t convert the value or if the converted value doesn’t pass any specified validation rules, + it adds an error to the ModelState.Errors collection. You can check ModelState.IsValid in your action method to see if any + errors occurred during model binding.
  10. +
  11. Action Method Execution The action method is executed with the bound parameters. If ModelState.IsValid is + false, you can handle the errors in your action method and return an appropriate response.
  12. +
+

See the links in the Resources section for more information.

+

How to fix it in ASP.NET MVC 4.x

+

Request.Form and Request.QueryString are keyed collections +that expose data from the incoming HTTP request:

+ +

Model binding can bind these keyed collections to

+ +

To replace the keyed collection access, you can:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Replacewith parameter bindingor complex type binding

Request.Form["id"]

optional [Bind] attribute on the + parameter or a FormCollection + parameter

optional [Bind] attribute on the + parameter or type

Request.QueryString["id"]

optional [Bind] attribute on the + parameter

property name must match query parameter key

+

Code examples

+

Noncompliant code example

+
+public ActionResult Post()
+{
+    var name = Request.Form["name"];                            // Noncompliant: Request.Form
+    Debug.WriteLine(Request.Form[0]);                           // Compliant: Binding by index is not supported.
+    var birthdate = DateTime.Parse(Request.Form["Birthdate"]);  // Noncompliant: Request.Form
+
+    var cultureName = Request.QueryString["locale"] ?? "en-US"; // Noncompliant: Request.QueryString
+    // ..
+}
+
+

Compliant solution

+
+public class User
+{
+    [Required, StringLength(100)]
+    public string Name { get; set; }
+    [DataType(DataType.Date)]
+    public DateTime? Birthdate { get; set; }
+}
+
+public ActionResult Post(User user, [Bind(Prefix = "locale")] string cultureName = "en-US")
+{
+    if (ModelState.IsValid)
+    {
+        // ...
+    }
+}
+
+

How does this work?

+

Model binding in ASP.NET Core MVC and ASP.NET MVC 4.x works by automatically mapping data from HTTP requests to action method parameters. Here’s a +step-by-step breakdown of how it works:

+
    +
  1. Request Data When a user submits a form or sends a request to an ASP.NET application, the request data might include form + data, query string parameters, request body, and HTTP headers.
  2. +
  3. Model Binder The model binder’s job is to create .NET objects from the request data. It looks at each parameter in the action + method and attempts to populate it with the incoming data.
  4. +
  5. Value Providers The model binder uses Value Providers to get data from various parts of the request, such as the query string, + form data, or route data. Each value provider tells the model binder where to find values in the request.
  6. +
  7. Binding The model binder tries to match the keys from the incoming data with the properties of the action method’s parameters. + If a match is found, it attempts to convert the incoming data into the appropriate .NET type and assigns it to the parameter.
  8. +
  9. Validation If the model binder can’t convert the value or if the converted value doesn’t pass any specified validation rules, + it adds an error to the ModelState.Errors collection. You can check ModelState.IsValid in your action method to see if any + errors occurred during model binding.
  10. +
  11. Action Method Execution The action method is executed with the bound parameters. If ModelState.IsValid is + false, you can handle the errors in your action method and return an appropriate response.
  12. +
+

See the links in the Resources section for more information.

+

Resources

+

Documentation

+ + diff --git a/analyzers/rspec/cs/S6932.json b/analyzers/rspec/cs/S6932.json new file mode 100644 index 00000000000..24c4c6e7f45 --- /dev/null +++ b/analyzers/rspec/cs/S6932.json @@ -0,0 +1,25 @@ +{ + "title": "Use model binding instead of reading raw request data", + "type": "CODE_SMELL", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5min" + }, + "tags": [ + "asp.net" + ], + "defaultSeverity": "Major", + "ruleSpecification": "RSPEC-6932", + "sqKey": "S6932", + "scope": "Main", + "quickfix": "infeasible", + "code": { + "impacts": { + "MAINTAINABILITY": "HIGH", + "RELIABILITY": "MEDIUM", + "SECURITY": "MEDIUM" + }, + "attribute": "FOCUSED" + } +} diff --git a/analyzers/src/SonarAnalyzer.CSharp/Rules/UseAspNetModelBinding.cs b/analyzers/src/SonarAnalyzer.CSharp/Rules/UseAspNetModelBinding.cs new file mode 100644 index 00000000000..36d58d5889b --- /dev/null +++ b/analyzers/src/SonarAnalyzer.CSharp/Rules/UseAspNetModelBinding.cs @@ -0,0 +1,261 @@ +/* + * SonarAnalyzer for .NET + * Copyright (C) 2015-2024 SonarSource SA + * mailto: contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +using System.Collections.Concurrent; + +namespace SonarAnalyzer.Rules.CSharp; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UseAspNetModelBinding : SonarDiagnosticAnalyzer +{ + private const string DiagnosticId = "S6932"; + private const string UseAspNetModelBindingMessage = "Use model binding instead of accessing the raw request data"; + private const string UseIFormFileBindingMessage = "Use IFormFile or IFormFileCollection binding instead"; + + protected override string MessageFormat => "{0}"; + + protected override ILanguageFacade Language => CSharpFacade.Instance; + + public UseAspNetModelBinding() : base(DiagnosticId) { } + + protected override void Initialize(SonarAnalysisContext context) + { + context.RegisterCompilationStartAction(compilationStartContext => + { + var (argumentDescriptors, propertyAccessDescriptors) = GetDescriptors(compilationStartContext.Compilation); + if (argumentDescriptors.Any() || propertyAccessDescriptors.Any()) + { + compilationStartContext.RegisterSymbolStartAction(symbolStart => + { + // If the user overrides any action filters, model binding may not be working as expected. + // Then we do not want to raise on expressions that originate from parameters. + // See the OverridesController.Undecidable test cases for details. + var hasActionFiltersOverrides = false; + var candidates = new ConcurrentStack(); // In SymbolEnd, we filter the candidates based on the overriding we learn on the go. + if (symbolStart.Symbol is INamedTypeSymbol namedType && namedType.IsControllerType()) + { + symbolStart.RegisterCodeBlockStartAction(codeBlockStart => + { + if (IsOverridingFilterMethods(codeBlockStart.OwningSymbol)) + { + // We do not want to raise in ActionFilter overrides and so we do not register. + // The SymbolEndAction needs to be made aware, that there are + // ActionFilter overrides, so it can filter out some candidates. + hasActionFiltersOverrides = true; + } + else + { + RegisterCodeBlockActions(codeBlockStart, argumentDescriptors, propertyAccessDescriptors, candidates); + } + }); + } + symbolStart.RegisterSymbolEndAction(symbolEnd => + { + foreach (var candidate in candidates.Where(x => !(hasActionFiltersOverrides && x.OriginatesFromParameter))) + { + symbolEnd.ReportIssue(Diagnostic.Create(Rule, candidate.Location, candidate.Message)); + } + }); + }, SymbolKind.NamedType); + } + }); + } + + private void RegisterCodeBlockActions(SonarCodeBlockStartAnalysisContext codeBlockStart, + ArgumentDescriptor[] argumentDescriptors, MemberDescriptor[] propertyAccessDescriptors, + ConcurrentStack controllerCandidates) + { + // Within a single code block, access via constant and variable keys could be mixed. + // We only want to raise, if all access were done via constants. + var allConstantAccesses = true; + var codeBlockCandidates = new ConcurrentStack(); + if (argumentDescriptors.Any()) + { + codeBlockStart.RegisterNodeAction(nodeContext => + { + var argument = (ArgumentSyntax)nodeContext.Node; + var context = new ArgumentContext(argument, nodeContext.SemanticModel); + if (allConstantAccesses && Array.Exists(argumentDescriptors, x => Language.Tracker.Argument.MatchArgument(x)(context))) + { + allConstantAccesses &= nodeContext.SemanticModel.GetConstantValue(argument.Expression) is { HasValue: true, Value: string }; + codeBlockCandidates.Push(new(UseAspNetModelBindingMessage, GetPrimaryLocation(argument), IsOriginatingFromParameter(nodeContext.SemanticModel, argument))); + } + }, SyntaxKind.Argument); + } + if (propertyAccessDescriptors.Any()) + { + codeBlockStart.RegisterNodeAction(nodeContext => + { + // The property access of Request.Form.Files can be replaced by an IFormFile binding. + // Any access to a "Files" property is therefore noncompliant. This is different from the Argument handling above. + var memberAccess = (MemberAccessExpressionSyntax)nodeContext.Node; + var context = new PropertyAccessContext(memberAccess, nodeContext.SemanticModel, memberAccess.Name.Identifier.ValueText); + if (Language.Tracker.PropertyAccess.MatchProperty(propertyAccessDescriptors)(context)) + { + codeBlockCandidates.Push(new(UseIFormFileBindingMessage, memberAccess.GetLocation(), IsOriginatingFromParameter(nodeContext.SemanticModel, memberAccess))); + } + }, SyntaxKind.SimpleMemberAccessExpression); + } + codeBlockStart.RegisterCodeBlockEndAction(codeBlockEnd => + { + if (allConstantAccesses) + { + controllerCandidates.PushRange([.. codeBlockCandidates]); + } + }); + } + + private static (ArgumentDescriptor[] ArgumentDescriptors, MemberDescriptor[] PropertyAccessDescriptors) GetDescriptors(Compilation compilation) + { + var argumentDescriptors = new List(); + var propertyAccessDescriptors = new List(); + if (compilation.GetTypeByMetadataName(KnownType.Microsoft_AspNetCore_Mvc_ControllerAttribute) is { }) + { + AddAspNetCoreDescriptors(argumentDescriptors, propertyAccessDescriptors); + } + // TODO: Add descriptors for Asp.Net MVC 4.x + return ([.. argumentDescriptors], [.. propertyAccessDescriptors]); + } + + private static void AddAspNetCoreDescriptors(List argumentDescriptors, List propertyAccessDescriptors) + { + argumentDescriptors.AddRange([ + ArgumentDescriptor.ElementAccess(// Request.Form["id"] + invokedIndexerContainer: KnownType.Microsoft_AspNetCore_Http_IFormCollection, + invokedIndexerExpression: "Form", + parameterConstraint: _ => true, // There is only a single overload and it is getter only + argumentPosition: 0), + ArgumentDescriptor.MethodInvocation(// Request.Form.TryGetValue("id", out _) + invokedType: KnownType.Microsoft_AspNetCore_Http_IFormCollection, + methodName: "TryGetValue", + parameterName: "key", + argumentPosition: 0), + ArgumentDescriptor.MethodInvocation(// Request.Form.ContainsKey("id") + invokedType: KnownType.Microsoft_AspNetCore_Http_IFormCollection, + methodName: "ContainsKey", + parameterName: "key", + argumentPosition: 0), + ArgumentDescriptor.ElementAccess(// Request.Headers["id"] + invokedIndexerContainer: KnownType.Microsoft_AspNetCore_Http_IHeaderDictionary, + invokedIndexerExpression: "Headers", + parameterConstraint: IsGetterParameter, // Headers are read/write + argumentPosition: 0), + ArgumentDescriptor.MethodInvocation(// Request.Headers.TryGetValue("id", out _) + invokedMethodSymbol: x => IsIDictionaryStringStringValuesInvocation(x, "TryGetValue"), // TryGetValue is from IDictionary here. We check the type arguments. + invokedMemberNameConstraint: (name, comparison) => string.Equals(name, "TryGetValue", comparison), + invokedMemberNodeConstraint: IsAccessedViaHeaderDictionary, + parameterConstraint: x => string.Equals(x.Name, "key", StringComparison.Ordinal), + argumentListConstraint: (list, position) => list.Count == 2 && position is 0 or null, + refKind: RefKind.None), + ArgumentDescriptor.MethodInvocation(// Request.Headers.ContainsKey("id") + invokedMethodSymbol: x => IsIDictionaryStringStringValuesInvocation(x, "ContainsKey"), + invokedMemberNameConstraint: (name, comparison) => string.Equals(name, "ContainsKey", comparison), + invokedMemberNodeConstraint: IsAccessedViaHeaderDictionary, + parameterConstraint: x => string.Equals(x.Name, "key", StringComparison.Ordinal), + argumentListConstraint: (list, _) => list.Count == 1, + refKind: RefKind.None), + ArgumentDescriptor.ElementAccess(// Request.Query["id"] + invokedIndexerContainer: KnownType.Microsoft_AspNetCore_Http_IQueryCollection, + invokedIndexerExpression: "Query", + parameterConstraint: _ => true, // There is only a single overload and it is getter only + argumentPosition: 0), + ArgumentDescriptor.MethodInvocation(// Request.Query.TryGetValue("id", out _) + invokedType: KnownType.Microsoft_AspNetCore_Http_IQueryCollection, + methodName: "TryGetValue", + parameterName: "key", + argumentPosition: 0), + ArgumentDescriptor.ElementAccess(// Request.RouteValues["id"] + invokedIndexerContainer: KnownType.Microsoft_AspNetCore_Routing_RouteValueDictionary, + invokedIndexerExpression: "RouteValues", + parameterConstraint: IsGetterParameter, // RouteValues are read/write + argumentPosition: 0), + ArgumentDescriptor.MethodInvocation(// Request.RouteValues.TryGetValue("id", out _) + invokedType: KnownType.Microsoft_AspNetCore_Routing_RouteValueDictionary, + methodName: "TryGetValue", + parameterName: "key", + argumentPosition: 0)]); + + propertyAccessDescriptors.Add(new(KnownType.Microsoft_AspNetCore_Http_IFormCollection, "Files")); // Request.Form.Files... + } + + // Check that the "Headers" expression in the Headers.TryGetValue("id", out _) invocation is of type IHeaderDictionary + private static bool IsAccessedViaHeaderDictionary(SemanticModel model, ILanguageFacade language, SyntaxNode invocation) => + invocation is InvocationExpressionSyntax { Expression: { } expression } + && GetLeftOfDot(expression) is { } left + && model.GetTypeInfo(left) is { Type: { } typeSymbol } && typeSymbol.Is(KnownType.Microsoft_AspNetCore_Http_IHeaderDictionary); + + private static bool IsOverridingFilterMethods(ISymbol owningSymbol) => + (owningSymbol.GetOverriddenMember() ?? owningSymbol).ExplicitOrImplicitInterfaceImplementations().Any(x => x is IMethodSymbol { ContainingType: { } container } + && container.IsAny( + KnownType.Microsoft_AspNetCore_Mvc_Filters_IActionFilter, + KnownType.Microsoft_AspNetCore_Mvc_Filters_IAsyncActionFilter)); + + private static bool IsOriginatingFromParameter(SemanticModel semanticModel, ArgumentSyntax argument) => + GetExpressionOfArgumentParent(argument) is { } parentExpression && IsOriginatingFromParameter(semanticModel, parentExpression); + + private static bool IsOriginatingFromParameter(SemanticModel semanticModel, ExpressionSyntax expression) => + MostLeftOfDottedChain(expression) is { } mostLeft && semanticModel.GetSymbolInfo(mostLeft).Symbol is IParameterSymbol; + + private static ExpressionSyntax GetLeftOfDot(ExpressionSyntax expression) => + expression switch + { + MemberAccessExpressionSyntax memberAccessExpression => memberAccessExpression.Expression, + MemberBindingExpressionSyntax memberBindingExpression => memberBindingExpression.GetParentConditionalAccessExpression()?.Expression, + _ => null, + }; + + private static ExpressionSyntax MostLeftOfDottedChain(ExpressionSyntax root) + { + var current = root.GetRootConditionalAccessExpression()?.Expression ?? root; + while (current.Kind() is SyntaxKind.SimpleMemberAccessExpression or SyntaxKind.ElementAccessExpression) + { + current = current switch + { + MemberAccessExpressionSyntax { Expression: { } left } => left, + ElementAccessExpressionSyntax { Expression: { } left } => left, + _ => throw new InvalidOperationException("Unreachable"), + }; + } + return current; + } + + private static ExpressionSyntax GetExpressionOfArgumentParent(ArgumentSyntax argument) => + argument switch + { + { Parent: BracketedArgumentListSyntax { Parent: ElementBindingExpressionSyntax expression } } => expression.GetParentConditionalAccessExpression(), + { Parent: BracketedArgumentListSyntax { Parent: ElementAccessExpressionSyntax { Expression: { } expression } } } => expression, + { Parent: ArgumentListSyntax { Parent: InvocationExpressionSyntax { Expression: { } expression } } } => expression, + _ => null, + }; + + private static Location GetPrimaryLocation(ArgumentSyntax argument) => + ((SyntaxNode)GetExpressionOfArgumentParent(argument) ?? argument).GetLocation(); + + private static bool IsGetterParameter(IParameterSymbol parameter) => + parameter.ContainingSymbol is IMethodSymbol { MethodKind: MethodKind.PropertyGet }; + + private static bool IsIDictionaryStringStringValuesInvocation(IMethodSymbol method, string name) => + method.Is(KnownType.System_Collections_Generic_IDictionary_TKey_TValue, name) + && method.ContainingType.TypeArguments is { Length: 2 } typeArguments + && typeArguments[0].Is(KnownType.System_String) + && typeArguments[1].Is(KnownType.Microsoft_Extensions_Primitives_StringValues); + + private readonly record struct ReportCandidate(string Message, Location Location, bool OriginatesFromParameter); +} diff --git a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs index 4e0e9834b1a..05cd7ef8d69 100644 --- a/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs +++ b/analyzers/src/SonarAnalyzer.Common/Helpers/KnownType.cs @@ -69,6 +69,7 @@ public sealed partial class KnownType public static readonly KnownType Microsoft_AspNetCore_Http_IFormFile = new("Microsoft.AspNetCore.Http.IFormFile"); public static readonly KnownType Microsoft_AspNetCore_Http_IFormFileCollection = new("Microsoft.AspNetCore.Http.IFormFileCollection"); public static readonly KnownType Microsoft_AspNetCore_Http_IHeaderDictionary = new("Microsoft.AspNetCore.Http.IHeaderDictionary"); + public static readonly KnownType Microsoft_AspNetCore_Http_IQueryCollection = new("Microsoft.AspNetCore.Http.IQueryCollection"); public static readonly KnownType Microsoft_AspNetCore_Http_IRequestCookieCollection = new("Microsoft.AspNetCore.Http.IRequestCookieCollection"); public static readonly KnownType Microsoft_AspNetCore_Http_IResponseCookies = new("Microsoft.AspNetCore.Http.IResponseCookies"); public static readonly KnownType Microsoft_AspNetCore_Http_IResult = new("Microsoft.AspNetCore.Http.IResult"); @@ -83,6 +84,8 @@ public sealed partial class KnownType public static readonly KnownType Microsoft_AspNetCore_Mvc_ControllerAttribute = new("Microsoft.AspNetCore.Mvc.ControllerAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_DisableRequestSizeLimitAttribute = new("Microsoft.AspNetCore.Mvc.DisableRequestSizeLimitAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_Filters_ActionFilterAttribute = new("Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_Filters_IActionFilter = new("Microsoft.AspNetCore.Mvc.Filters.IActionFilter"); + public static readonly KnownType Microsoft_AspNetCore_Mvc_Filters_IAsyncActionFilter = new("Microsoft.AspNetCore.Mvc.Filters.IAsyncActionFilter"); public static readonly KnownType Microsoft_AspNetCore_Mvc_FromServicesAttribute = new("Microsoft.AspNetCore.Mvc.FromServicesAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpDeleteAttribute = new("Microsoft.AspNetCore.Mvc.HttpDeleteAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_HttpGetAttribute = new("Microsoft.AspNetCore.Mvc.HttpGetAttribute"); @@ -109,6 +112,7 @@ public sealed partial class KnownType public static readonly KnownType Microsoft_AspNetCore_Mvc_RouteAttribute = new("Microsoft.AspNetCore.Mvc.RouteAttribute"); public static readonly KnownType Microsoft_AspNetCore_Mvc_Routing_HttpMethodAttribute = new("Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute"); public static readonly KnownType Microsoft_AspNetCore_Razor_Hosting_RazorCompiledItemAttribute = new("Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemAttribute"); + public static readonly KnownType Microsoft_AspNetCore_Routing_RouteValueDictionary = new("Microsoft.AspNetCore.Routing.RouteValueDictionary"); public static readonly KnownType Microsoft_Azure_Cosmos_CosmosClient = new("Microsoft.Azure.Cosmos.CosmosClient"); public static readonly KnownType Microsoft_Azure_Documents_Client_DocumentClient = new("Microsoft.Azure.Documents.Client.DocumentClient"); public static readonly KnownType Microsoft_Azure_ServiceBus_Management_ManagementClient = new("Microsoft.Azure.ServiceBus.Management.ManagementClient"); diff --git a/analyzers/src/SonarAnalyzer.Common/Trackers/PropertyAccessContext.cs b/analyzers/src/SonarAnalyzer.Common/Trackers/PropertyAccessContext.cs index a31699a9d90..c331e3d7f1d 100644 --- a/analyzers/src/SonarAnalyzer.Common/Trackers/PropertyAccessContext.cs +++ b/analyzers/src/SonarAnalyzer.Common/Trackers/PropertyAccessContext.cs @@ -30,5 +30,11 @@ public PropertyAccessContext(SonarSyntaxNodeReportingContext context, string pro PropertyName = propertyName; PropertySymbol = new Lazy(() => context.SemanticModel.GetSymbolInfo(context.Node).Symbol as IPropertySymbol); } + + public PropertyAccessContext(SyntaxNode node, SemanticModel semanticModel, string propertyName) : base(node, semanticModel) + { + PropertyName = propertyName; + PropertySymbol = new Lazy(() => semanticModel.GetSymbolInfo(node).Symbol as IPropertySymbol); + } } } diff --git a/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs b/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs index ba7bd4005c3..b12a0f5fec9 100644 --- a/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs +++ b/analyzers/tests/SonarAnalyzer.Test/PackagingTests/RuleTypeMappingCS.cs @@ -6856,8 +6856,7 @@ internal static class RuleTypeMappingCS // ["S6929"], ["S6930"] = "BUG", ["S6931"] = "CODE_SMELL", - // ["S6932"], - // ["S6933"], + ["S6932"] = "CODE_SMELL", ["S6934"] = "CODE_SMELL", // ["S6935"], // ["S6936"], diff --git a/analyzers/tests/SonarAnalyzer.Test/Rules/UseAspNetModelBindingTest.cs b/analyzers/tests/SonarAnalyzer.Test/Rules/UseAspNetModelBindingTest.cs new file mode 100644 index 00000000000..9ca56824c70 --- /dev/null +++ b/analyzers/tests/SonarAnalyzer.Test/Rules/UseAspNetModelBindingTest.cs @@ -0,0 +1,296 @@ +/* + * SonarAnalyzer for .NET + * Copyright (C) 2015-2024 SonarSource SA + * mailto: contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SonarAnalyzer.Rules.CSharp; + +namespace SonarAnalyzer.Test.Rules; + +[TestClass] +public class UseAspNetModelBindingTest +{ +#if NET + private readonly VerifierBuilder builderAspNetCore = new VerifierBuilder() + .WithOptions(ParseOptionsHelper.FromCSharp12) + .AddReferences([ + AspNetCoreMetadataReference.MicrosoftAspNetCoreMvcCore, + AspNetCoreMetadataReference.MicrosoftAspNetCoreHttpAbstractions, + AspNetCoreMetadataReference.MicrosoftAspNetCoreMvcViewFeatures, + AspNetCoreMetadataReference.MicrosoftAspNetCoreMvcAbstractions, + AspNetCoreMetadataReference.MicrosoftAspNetCoreHttpFeatures, + AspNetCoreMetadataReference.MicrosoftExtensionsPrimitives, + ]); + + [TestMethod] + public void UseAspNetModelBinding_NoRegistrationIfNotAspNet() => + new VerifierBuilder().AddSnippet(string.Empty).Verify(); + + [TestMethod] + public void UseAspNetModelBinding_AspNetCore_CSharp12() => + builderAspNetCore.AddPaths("UseAspNetModelBinding_AspNetCore.cs").Verify(); + + [DataTestMethod] + [DataRow("Form")] + [DataRow("Query")] + [DataRow("RouteValues")] + [DataRow("Headers")] + public void UseAspNetModelBinding_NonCompliantAccess(string property) => + builderAspNetCore.AddSnippet($$"""" + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using System; + using System.Linq; + using System.Threading.Tasks; + + public class TestController : Controller + { + async Task NoncompliantKeyVariations() + { + _ = Request.{{property}}[@"key"]; // Noncompliant + _ = Request.{{property}}.TryGetValue(@"key", out _); // Noncompliant + _ = Request.{{property}}["""key"""]; // Noncompliant + _ = Request.{{property}}.TryGetValue("""key""", out _); // Noncompliant + + const string key = "id"; + _ = Request.{{property}}[key]; // Noncompliant + _ = Request.{{property}}.TryGetValue(key, out _); // Noncompliant + _ = Request.{{property}}[$"prefix.{key}"]; // Noncompliant + _ = Request.{{property}}.TryGetValue($"prefix.{key}", out _); // Noncompliant + _ = Request.{{property}}[$"""prefix.{key}"""]; // Noncompliant + _ = Request.{{property}}.TryGetValue($"""prefix.{key}""", out _); // Noncompliant + + _ = Request.{{property}}[key: "id"]; // Noncompliant + _ = Request.{{property}}.TryGetValue(value: out _, key: "id"); // Noncompliant + } + + private static void HandleRequest(HttpRequest request) + { + _ = request.{{property}}["id"]; // Noncompliant: Containing type is a controller + void LocalFunction() + { + _ = request.{{property}}["id"]; // Noncompliant: Containing type is a controller + } + static void StaticLocalFunction(HttpRequest request) + { + _ = request.{{property}}["id"]; // Noncompliant: Containing type is a controller + } + } + } + """").Verify(); + /* + * Depends on https://github.com/SonarSource/sonar-dotnet/pull/8949 + [TestMethod] + [CombinatorialData] + public void UseAspNetModelBinding_CompliantAccess( + [DataValues( + "_ = {0}.Keys", + "_ = {0}.Count", + "foreach (var kvp in {0}) {{ }}", + "_ = {0}.Select(x => x);", + "_ = {0}[key]; // Compliant: The accessed key is not a compile time constant")] string statementFormat, + [DataValues("Request", "this.Request", "ControllerContext.HttpContext.Request", "request")] string request, + [DataValues("Form", "Headers", "Query", "RouteValues")] string property, + [DataValues("[FromForm]", "[FromQuery]", "[FromRoute]", "[FromHeader]")] string attribute) => + builderAspNetCore.AddSnippet($$""" + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using System; + using System.Linq; + using System.Threading.Tasks; + + public class TestController : Controller + { + async Task Compliant({{attribute}} string key, HttpRequest request) + { + {{string.Format(statementFormat, $"{request}.{property}")}}; + } + } + """).Verify(); + */ + + [DataTestMethod] + [DataRow("Form")] + [DataRow("Headers")] + [DataRow("Query")] + [DataRow("RouteValues")] + public void UseAspNetModelBinding_DottedExpressions(string property) => + builderAspNetCore.AddSnippet($$""" + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using Microsoft.AspNetCore.Routing; + using System; + using System.Linq; + using System.Threading.Tasks; + + public class TestController : Controller + { + HttpRequest ValidRequest => Request; + IFormCollection Form => Request.Form; + IHeaderDictionary Headers => Request.Headers; + IQueryCollection Query => Request.Query; + RouteValueDictionary RouteValues => Request.RouteValues; + + async Task DottedExpressions() + { + _ = (true ? Request : Request).{{property}}["id"]; // Noncompliant + _ = ValidatedRequest().{{property}}["id"]; // Noncompliant + _ = ValidRequest.{{property}}["id"]; // Noncompliant + _ = {{property}}["id"]; // Noncompliant + _ = this.{{property}}["id"]; // Noncompliant + _ = new TestController().{{property}}["id"]; // Noncompliant + + _ = this.Request.{{property}}["id"]; // Noncompliant + _ = Request?.{{property}}?["id"]; // Noncompliant + _ = Request?.{{property}}?.TryGetValue("id", out _); // Noncompliant + _ = Request.{{property}}?.TryGetValue("id", out _); // Noncompliant + _ = Request.{{property}}?.TryGetValue("id", out _).ToString(); // Noncompliant + _ = HttpContext.Request.{{property}}["id"]; // Noncompliant + _ = Request.HttpContext.Request.{{property}}["id"]; // Noncompliant + _ = this.ControllerContext.HttpContext.Request.{{property}}["id"]; // Noncompliant + var r1 = HttpContext.Request; + _ = r1.{{property}}["id"]; // Noncompliant + var r2 = ControllerContext; + _ = r2.HttpContext.Request.{{property}}["id"]; // Noncompliant + + HttpRequest ValidatedRequest() => Request; + } + } + """).Verify(); + + [DataTestMethod] + [DataRow("public class MyController: Controller")] + [DataRow("public class MyController: ControllerBase")] + [DataRow("[Controller] public class My: Controller")] + // [DataRow("public class MyController")] FN: Poco controller are not detected + public void UseAspNetModelBinding_PocoController(string classDeclaration) => + builderAspNetCore.AddSnippet($$"""" + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using System; + using System.Linq; + using System.Threading.Tasks; + + {{classDeclaration}} + { + public async Task Action([FromServices]IHttpContextAccessor httpContextAccessor) + { + _ = httpContextAccessor.HttpContext.Request.Form["id"]; // Noncompliant + } + } + """").Verify(); + + [DataTestMethod] + [DataRow("public class My")] + [DataRow("[NonController] public class My: Controller")] + [DataRow("[NonController] public class MyController: Controller")] + public void UseAspNetModelBinding_NoController(string classDeclaration) => + builderAspNetCore.AddSnippet($$"""" + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using System; + using System.Linq; + using System.Threading.Tasks; + + {{classDeclaration}} + { + public async Task Action([FromServices]IHttpContextAccessor httpContextAccessor) + { + _ = httpContextAccessor.HttpContext.Request.Form["id"]; // Compliant + } + } + """").Verify(); + + [DataTestMethod] + [DataRow("Form")] + [DataRow("Headers")] + [DataRow("Query")] + [DataRow("RouteValues")] + public void UseAspNetModelBinding_NoControllerHelpers(string property) => + builderAspNetCore.AddSnippet($$"""" + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using System; + using System.Linq; + using System.Threading.Tasks; + + static class HttpRequestExtensions + { + public static void Ext(this HttpRequest request) + { + _ = request.{{property}}["id"]; // Compliant: Not in a controller + } + } + + class RequestService + { + public HttpRequest Request { get; } + + public void HandleRequest(HttpRequest request) + { + _ = Request.{{property}}["id"]; // Compliant: Not in a controller + _ = request.{{property}}["id"]; // Compliant: Not in a controller + } + } + """").Verify(); + + /* + * Depends on https://github.com/SonarSource/sonar-dotnet/pull/8949 + [TestMethod] + [CombinatorialData] + public void UseAspNetModelBinding_InheritanceAccess( + [DataValues( + ": Controller", + ": ControllerBase", + ": MyBaseController", + ": MyBaseBaseController")]string baseList, + [DataValues( + """_ = Request.Form["id"]""", + """_ = Request.Form.TryGetValue("id", out var _)""", + """_ = Request.Headers["id"]""", + """_ = Request.Query["id"]""", + """_ = Request.RouteValues["id"]""")]string nonCompliantStatement) => + builderAspNetCore.AddSnippet($$"""" + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Mvc.Filters; + using System; + using System.Linq; + using System.Threading.Tasks; + + public class MyBaseController : ControllerBase { } + public class MyBaseBaseController : MyBaseController { } + + public class MyTestController {{baseList}} + { + public void Action() + { + {{nonCompliantStatement}}; // Noncompliant + } + } + """").Verify(); + */ +#endif +} diff --git a/analyzers/tests/SonarAnalyzer.Test/TestCases/UseAspNetModelBinding_AspNetCore.cs b/analyzers/tests/SonarAnalyzer.Test/TestCases/UseAspNetModelBinding_AspNetCore.cs new file mode 100644 index 00000000000..5e47dd444f9 --- /dev/null +++ b/analyzers/tests/SonarAnalyzer.Test/TestCases/UseAspNetModelBinding_AspNetCore.cs @@ -0,0 +1,214 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using System; +using System.Linq; +using System.Threading.Tasks; + +public class TestController : Controller +{ + private readonly string Key = "id"; + + public IActionResult Post(string key) + { + _ = Request.Form["id"]; // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^ + _ = Request.Form.TryGetValue("id", out _); // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^^^^^^^^^^ + _ = Request.Form.ContainsKey("id"); // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^^^^^^^^^^ + _ = Request.Headers["id"]; // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^ + _ = Request.Headers.TryGetValue("id", out _); // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + _ = Request.Headers.ContainsKey("id"); // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + _ = Request.Query["id"]; // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^ + _ = Request.Query.TryGetValue("id", out _); // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^^^^^^^^^^^ + _ = Request.RouteValues["id"]; // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^^^^^ + _ = Request.RouteValues.TryGetValue("id", out _); // Noncompliant {{Use model binding instead of accessing the raw request data}} + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files; // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + _ = Request.Form.File\u0073; // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files["file"]; // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files[key]; // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files[0]; // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files.Any(); // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files.Count; // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files.GetFile("file"); // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + _ = Request.Form.Files.GetFiles("file"); // Noncompliant {{Use IFormFile or IFormFileCollection binding instead}} + // ^^^^^^^^^^^^^^^^^^ + return default; + } + + void MixedAccess_Form(string key) + { + _ = Request.Form["id"]; // Compliant (a mixed access with constant and non-constant keys is compliant) + _ = Request.Form[key]; // Compliant + } + + void MixedAccess_Form_Query(string key) + { + _ = Request.Form["id"]; // Compliant (a mixed access with constant and non-constant keys is compliant) + _ = Request.Query[key]; // Compliant + } + + void FalseNegatives() + { + string localKey = "id"; + _ = Request.Form[localKey]; // FN (Requires SE) + _ = Request.Form[Key]; // FN: Key is a readonly field with a constant initializer (Requires cross procedure SE) + } + + void FormCollection(IFormCollection form) + { + _ = form["id"]; // Compliant. Using IFormCollection is model binding + } + + void WriteAccess() + { + Request.Headers["id"] = "Assignment"; // Compliant + Request.RouteValues["id"] = "Assignment"; // Compliant + } + + async Task Compliant() + { + _ = Request.Cookies["cookie"]; // Compliant: Cookies are not bound by default + _ = Request.QueryString; // Compliant: Accessing the whole raw string is fine. + } + + async Task CompliantFormAccess() + { + var form = await Request.ReadFormAsync(); // Compliant: This might be used for optimization purposes e.g. conditional form value access. + _ = form["id"]; + } +} + +public class CodeBlocksController : Controller +{ + public CodeBlocksController() + { + _ = Request.Form["id"]; // Noncompliant + } + + public CodeBlocksController(object o) => _ = Request.Form["id"]; // Noncompliant + + HttpRequest ValidRequest => Request; + IFormCollection Form => Request.Form; + + string P1 => Request.Form["id"]; // Noncompliant + string P2 + { + get => Request.Form["id"]; // Noncompliant + } + string P3 + { + get + { + return Request.Form["id"]; // Noncompliant + } + } + void M1() => _ = Request.Form["id"]; // Noncompliant + void M2() + { + Func f1 = () => Request.Form["id"]; // Noncompliant + Func f2 = x => Request.Form["id"]; // Noncompliant + Func f3 = delegate (object x) { return Request.Form["id"]; }; // Noncompliant + } + void M3() + { + // see also parameterized test "DottedExpressions" + _ = Request.Form["id"][0]; // Noncompliant + _ = Request?.Form["id"][0]; // Noncompliant + _ = Request.Form?["id"][0]; // Noncompliant + _ = Request?.Form?["id"][0]; // Noncompliant + _ = Request.Form?["id"][0]; // Noncompliant + + _ = Request.Form?["id"][0][0]; // Noncompliant + _ = Request.Form?["id"][0]?[0]; // Noncompliant + _ = Request.Form["id"][0]?[0]; // Noncompliant + } + ~CodeBlocksController() => _ = Request.Form["id"]; // Noncompliant +} + +public class OverridesController : Controller +{ + public void Action() + { + _ = Request.Form["id"]; // Noncompliant + } + private void Undecidable(HttpContext context) + { + // Implementation: It might be difficult to distinguish between access to "Request" that originate from overrides vs. "Request" access that originate from action methods. + // This is especially true for "Request" which originate from parameters like here. We may need to redeclare such cases as FNs (see e.g HandleRequest above). + _ = context.Request.Form["id"]; // Undecidable: request may originate from an action method (which supports binding), or from one of the following overrides (which don't). + _ = context.Request.Form["id"][0]; + _ = context.Request?.Form["id"][0]; + _ = context.Request.Form?["id"][0]; + _ = context.Request?.Form?["id"][0]; + _ = context.Request.Form?["id"][0]; + + _ = context.Request.Form?["id"][0][0]; + _ = context.Request.Form?["id"][0]?[0]; + _ = context.Request.Form["id"][0]?[0]; + } + private void Undecidable(HttpRequest request) + { + _ = request.Form["id"]; // Undecidable: request may originate from an action method (which supports binding), or from one of the following overloads (which don't). + } + public override void OnActionExecuted(ActionExecutedContext context) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + } + public override void OnActionExecuting(ActionExecutingContext context) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + } + public override Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + return base.OnActionExecutionAsync(context, next); + } +} + +[Controller] +public class PocoController : IActionFilter, IAsyncActionFilter +{ + public void OnActionExecuted(ActionExecutedContext context) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + } + void IActionFilter.OnActionExecuted(Microsoft.AspNetCore.Mvc.Filters.ActionExecutedContext context) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + } + public void OnActionExecuting(ActionExecutingContext context) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + } + void IActionFilter.OnActionExecuting(ActionExecutingContext context) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + } + public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + return Task.CompletedTask; + } + Task IAsyncActionFilter.OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + _ = context.HttpContext.Request.Form["id"]; // Compliant: Model binding is not supported here + return Task.CompletedTask; + } +}