Skip to content

Commit 560f931

Browse files
authored
Warn when [Authorize] is overridden by [AllowAnymous] from "farther" away (#56244)
1 parent adf603f commit 560f931

File tree

8 files changed

+897
-41
lines changed

8 files changed

+897
-41
lines changed

src/Framework/AspNetCoreAnalyzers/samples/WebAppSample/WebAppSample.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk.Web">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>

src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,13 @@ internal static class DiagnosticDescriptors
214214
DiagnosticSeverity.Info,
215215
isEnabledByDefault: true,
216216
helpLinkUri: "https://aka.ms/aspnet/analyzers");
217+
218+
internal static readonly DiagnosticDescriptor OverriddenAuthorizeAttribute = new(
219+
"ASP0026",
220+
new LocalizableResourceString(nameof(Resources.Analyzer_OverriddenAuthorizeAttribute_Title), Resources.ResourceManager, typeof(Resources)),
221+
new LocalizableResourceString(nameof(Resources.Analyzer_OverriddenAuthorizeAttribute_Message), Resources.ResourceManager, typeof(Resources)),
222+
"Security",
223+
DiagnosticSeverity.Warning,
224+
isEnabledByDefault: true,
225+
helpLinkUri: "https://aka.ms/aspnet/analyzers");
217226
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics;
7+
using System.Linq;
8+
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.Diagnostics;
11+
12+
namespace Microsoft.AspNetCore.Analyzers.Mvc;
13+
14+
using WellKnownType = WellKnownTypeData.WellKnownType;
15+
16+
public partial class MvcAnalyzer
17+
{
18+
/// <summary>
19+
/// This tries to detect [Authorize] attributes that are unwittingly overridden by [AllowAnonymous] attributes that are "farther" away from a controller.
20+
/// </summary>
21+
/// <remarks>
22+
/// This might report the same [Authorize] attribute multiple times if it's on a shared base type, but we'd have to disable parallelization of the
23+
/// entire MvcAnalyzer to avoid that. We assume that this scenario is rare enough and that overreporting is benign enough to not warrant the performance hit.
24+
/// See AuthorizeOnControllerBaseWithMultipleChildren_AllowAnonymousOnControllerBaseBaseType_HasMultipleDiagnostics.
25+
/// </remarks>
26+
private static void DetectOverriddenAuthorizeAttributeOnController(SymbolAnalysisContext context, WellKnownTypes wellKnownTypes,
27+
INamedTypeSymbol controllerSymbol, List<AttributeInfo> authorizeAttributes, out string? allowAnonClass)
28+
{
29+
Debug.Assert(authorizeAttributes.Count is 0);
30+
31+
var isCheckingBaseType = false;
32+
allowAnonClass = null;
33+
34+
foreach (var currentClass in controllerSymbol.GetTypeHierarchy())
35+
{
36+
FindAuthorizeAndAllowAnonymous(wellKnownTypes, currentClass, isCheckingBaseType, authorizeAttributes, out var foundAllowAnonymous);
37+
if (foundAllowAnonymous)
38+
{
39+
// Anything we find after this would be farther away, so we can short circuit.
40+
ReportOverriddenAuthorizeAttributeDiagnosticsIfAny(context, authorizeAttributes, currentClass.Name);
41+
// Keep track of the nearest class with [AllowAnonymous] for later reporting of action-level [Authorize] attributes.
42+
allowAnonClass = currentClass.Name;
43+
return;
44+
}
45+
46+
isCheckingBaseType = true;
47+
}
48+
}
49+
50+
/// <summary>
51+
/// This tries to detect [Authorize] attributes that are unwittingly overridden by [AllowAnonymous] attributes that are "farther" away from a controller action.
52+
/// To do so, it first searches the action method and then the controller class. It repeats this process for each virtual method the action may override and for
53+
/// each base class the controller may inherit from. Since it searches for the attributes closest to the action first, it short circuits as soon as [AllowAnonymous] is found.
54+
/// If it has already detected a closer [Authorize] attribute, it reports a diagnostic at the [Authorize] attribute's location indicating that it will be overridden.
55+
/// </summary>
56+
private static void DetectOverriddenAuthorizeAttributeOnAction(SymbolAnalysisContext context, WellKnownTypes wellKnownTypes,
57+
IMethodSymbol actionSymbol, List<AttributeInfo> authorizeAttributes, string? allowAnonClass)
58+
{
59+
Debug.Assert(authorizeAttributes.Count is 0);
60+
61+
var isCheckingBaseType = false;
62+
var currentMethod = actionSymbol;
63+
64+
foreach (var currentClass in actionSymbol.ContainingType.GetTypeHierarchy())
65+
{
66+
bool foundAllowAnonymous;
67+
68+
if (currentMethod is not null && IsSameSymbol(currentMethod.ContainingType, currentClass))
69+
{
70+
FindAuthorizeAndAllowAnonymous(wellKnownTypes, currentMethod, isCheckingBaseType, authorizeAttributes, out foundAllowAnonymous);
71+
if (foundAllowAnonymous)
72+
{
73+
// [AllowAnonymous] was found on the action method. Anything we find after this would be farther away, so we short circuit.
74+
ReportOverriddenAuthorizeAttributeDiagnosticsIfAny(context, authorizeAttributes, currentMethod.ContainingType.Name, currentMethod.Name);
75+
return;
76+
}
77+
78+
currentMethod = currentMethod.OverriddenMethod;
79+
80+
// We've already checked the controller and any base classes for overridden attributes in DetectOverriddenAuthorizeAttributeOnController.
81+
// If there are no more base methods, and we are not tracking any unreported [Authorize] attributes that might be overridden by a class, we're done.
82+
if (currentMethod is null && (authorizeAttributes.Count is 0 || !isCheckingBaseType))
83+
{
84+
if (allowAnonClass is not null)
85+
{
86+
// We don't use allowAnonClass once we start checking overrides to avoid false positives. But if we found [Authorize] directly on a non-virtual
87+
// action, we can report it without rechecking the controller or its base types for [AllowAnonymous] when given a non-null allowAnonClass.
88+
ReportOverriddenAuthorizeAttributeDiagnosticsIfAny(context, authorizeAttributes, allowAnonClass);
89+
}
90+
91+
return;
92+
}
93+
}
94+
95+
// Now, we're mostly trying to detect [Authorize] on virtual actions which are not covered by allowAnonClass. Overridden [Authorize] attributes on classes
96+
// have mostly been reported already, but we still need to track those too just in case there is [AllowAnonymous] on a base method farther away.
97+
FindAuthorizeAndAllowAnonymous(wellKnownTypes, currentClass, isCheckingBaseType, authorizeAttributes, out foundAllowAnonymous);
98+
if (foundAllowAnonymous)
99+
{
100+
// We are only concerned with method-level [Authorize] attributes that are overridden by the [AllowAnonymous] found on this class.
101+
// Any child classes should have already been reported in DetectOverriddenAuthorizeAttributeOnController.
102+
ReportOverriddenAuthorizeAttributeDiagnosticsIfAny(context, authorizeAttributes.Where(a => a.IsTargetingMethod), currentClass.Name);
103+
return;
104+
}
105+
106+
isCheckingBaseType = true;
107+
}
108+
109+
Debug.Assert(currentMethod is null);
110+
}
111+
112+
private static bool IsSameSymbol(ISymbol? x, ISymbol? y) => SymbolEqualityComparer.Default.Equals(x, y);
113+
114+
private static bool IsInheritableAttribute(WellKnownTypes wellKnownTypes, INamedTypeSymbol attribute)
115+
{
116+
// [AttributeUsage] is sealed but inheritable.
117+
var attributeUsageAttributeType = wellKnownTypes.Get(WellKnownType.System_AttributeUsageAttribute);
118+
var attributeUsage = attribute.GetAttributes(attributeUsageAttributeType, inherit: true).FirstOrDefault();
119+
120+
if (attributeUsage is not null)
121+
{
122+
foreach (var arg in attributeUsage.NamedArguments)
123+
{
124+
if (arg.Key == nameof(AttributeUsageAttribute.Inherited))
125+
{
126+
return (bool)arg.Value.Value!;
127+
}
128+
}
129+
}
130+
131+
// If [AttributeUsage] is not found or the Inherited property is not set, the default is true.
132+
return true;
133+
}
134+
135+
private static bool IsMatchingAttribute(WellKnownTypes wellKnownTypes, INamedTypeSymbol attribute,
136+
INamedTypeSymbol commonAttribute, ITypeSymbol attributeInterface, bool mustBeInheritable)
137+
{
138+
// The "common" attribute is either [Authorize] or [AllowAnonymous] so we can skip the interface and inheritable checks.
139+
if (IsSameSymbol(attribute, commonAttribute))
140+
{
141+
return true;
142+
}
143+
144+
if (!attributeInterface.IsAssignableFrom(attribute))
145+
{
146+
return false;
147+
}
148+
149+
return !mustBeInheritable || IsInheritableAttribute(wellKnownTypes, attribute);
150+
}
151+
152+
private static void FindAuthorizeAndAllowAnonymous(WellKnownTypes wellKnownTypes, ISymbol symbol, bool isCheckingBaseType,
153+
List<AttributeInfo> authorizeAttributes, out bool foundAllowAnonymous)
154+
{
155+
AttributeData? localAuthorizeAttribute = null;
156+
List<AttributeData>? localAuthorizeAttributeOverflow = null;
157+
foundAllowAnonymous = false;
158+
159+
foreach (var attribute in symbol.GetAttributes())
160+
{
161+
if (attribute.AttributeClass is null)
162+
{
163+
continue;
164+
}
165+
166+
var authInterfaceType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Authorization_IAuthorizeData);
167+
var authAttributeType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Authorization_AuthorizeAttribute);
168+
if (IsMatchingAttribute(wellKnownTypes, attribute.AttributeClass, authAttributeType, authInterfaceType, isCheckingBaseType))
169+
{
170+
if (localAuthorizeAttribute is null)
171+
{
172+
localAuthorizeAttribute = attribute;
173+
}
174+
else
175+
{
176+
// This is ony allocated if there are multiple [Authorize] attributes on the same symbol which we assume is rare.
177+
localAuthorizeAttributeOverflow ??= [];
178+
localAuthorizeAttributeOverflow.Add(attribute);
179+
}
180+
}
181+
182+
var anonInterfaceType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Authorization_IAllowAnonymous);
183+
var anonAttributeType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Authorization_AllowAnonymousAttribute);
184+
if (IsMatchingAttribute(wellKnownTypes, attribute.AttributeClass, anonAttributeType, anonInterfaceType, isCheckingBaseType))
185+
{
186+
// If localAuthorizeAttribute is not null, [AllowAnonymous] came after [Authorize] on the same method or class. We assume
187+
// this closer [AllowAnonymous] was intended to override the [Authorize] attribute which it always does regardless of order.
188+
// [Authorize(...)] could still be useful for configuring the authentication scheme even if the endpoint allows anonymous requests.
189+
localAuthorizeAttribute = null;
190+
localAuthorizeAttributeOverflow?.Clear();
191+
foundAllowAnonymous = true;
192+
}
193+
}
194+
195+
if (localAuthorizeAttribute is not null)
196+
{
197+
var isTargetingMethod = symbol is IMethodSymbol;
198+
authorizeAttributes.Add(new(localAuthorizeAttribute, isTargetingMethod));
199+
foreach (var extraAttribute in localAuthorizeAttributeOverflow ?? Enumerable.Empty<AttributeData>())
200+
{
201+
authorizeAttributes.Add(new(extraAttribute, isTargetingMethod));
202+
}
203+
}
204+
}
205+
206+
private static void ReportOverriddenAuthorizeAttributeDiagnosticsIfAny(SymbolAnalysisContext context,
207+
IEnumerable<AttributeInfo> authorizeAttributes, string allowAnonClass, string? allowAnonMethod = null)
208+
{
209+
string? allowAnonLocation = null;
210+
211+
foreach (var authorizeAttribute in authorizeAttributes)
212+
{
213+
if (authorizeAttribute.AttributeData.ApplicationSyntaxReference is { } syntaxReference)
214+
{
215+
allowAnonLocation ??= allowAnonMethod is null ? allowAnonClass : $"{allowAnonClass}.{allowAnonMethod}";
216+
context.ReportDiagnostic(Diagnostic.Create(
217+
DiagnosticDescriptors.OverriddenAuthorizeAttribute,
218+
syntaxReference.GetSyntax(context.CancellationToken).GetLocation(),
219+
allowAnonLocation));
220+
}
221+
}
222+
}
223+
}

src/Framework/AspNetCoreAnalyzers/src/Analyzers/Mvc/MvcAnalyzer.cs

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ namespace Microsoft.AspNetCore.Analyzers.Mvc;
2222
public partial class MvcAnalyzer : DiagnosticAnalyzer
2323
{
2424
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(
25-
DiagnosticDescriptors.AmbiguousActionRoute
25+
DiagnosticDescriptors.AmbiguousActionRoute,
26+
DiagnosticDescriptors.OverriddenAuthorizeAttribute
2627
);
2728

2829
public override void Initialize(AnalysisContext context)
@@ -36,17 +37,34 @@ public override void Initialize(AnalysisContext context)
3637
var wellKnownTypes = WellKnownTypes.GetOrCreate(compilation);
3738
var routeUsageCache = RouteUsageCache.GetOrCreate(compilation);
3839

39-
var concurrentQueue = new ConcurrentQueue<List<ActionRoute>>();
40+
var concurrentQueue = new ConcurrentQueue<(List<ActionRoute> ActionRoutes, List<AttributeInfo> AuthorizeAttributes)>();
4041

4142
context.RegisterSymbolAction(context =>
4243
{
4344
var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;
45+
46+
// Visit Controllers
4447
if (MvcDetector.IsController(namedTypeSymbol, wellKnownTypes))
4548
{
4649
// Pool and reuse lists for each block.
47-
if (!concurrentQueue.TryDequeue(out var actionRoutes))
50+
if (!concurrentQueue.TryDequeue(out var pooledItems))
51+
{
52+
pooledItems.ActionRoutes = [];
53+
pooledItems.AuthorizeAttributes = [];
54+
}
55+
56+
DetectOverriddenAuthorizeAttributeOnController(context, wellKnownTypes, namedTypeSymbol, pooledItems.AuthorizeAttributes, out var allowAnonClass);
57+
pooledItems.AuthorizeAttributes.Clear();
58+
59+
// Visit Actions
60+
foreach (var member in namedTypeSymbol.GetMembers())
4861
{
49-
actionRoutes = new List<ActionRoute>();
62+
if (member is IMethodSymbol methodSymbol && MvcDetector.IsAction(methodSymbol, wellKnownTypes))
63+
{
64+
PopulateActionRoutes(context, wellKnownTypes, routeUsageCache, pooledItems.ActionRoutes, methodSymbol);
65+
DetectOverriddenAuthorizeAttributeOnAction(context, wellKnownTypes, methodSymbol, pooledItems.AuthorizeAttributes, allowAnonClass);
66+
pooledItems.AuthorizeAttributes.Clear();
67+
}
5068
}
5169

5270
RoutePatternTree? controllerRoutePattern = null;
@@ -60,50 +78,41 @@ public override void Initialize(AnalysisContext context)
6078
}
6179
}
6280

63-
PopulateActionRoutes(context, wellKnownTypes, routeUsageCache, namedTypeSymbol, actionRoutes);
64-
65-
DetectAmbiguousActionRoutes(context, wellKnownTypes, controllerRoutePattern, actionRoutes);
81+
DetectAmbiguousActionRoutes(context, wellKnownTypes, controllerRoutePattern, pooledItems.ActionRoutes);
6682

6783
// Return to the pool.
68-
actionRoutes.Clear();
69-
concurrentQueue.Enqueue(actionRoutes);
84+
pooledItems.ActionRoutes.Clear();
85+
concurrentQueue.Enqueue(pooledItems);
7086
}
7187
}, SymbolKind.NamedType);
7288
});
7389
}
7490

75-
private static void PopulateActionRoutes(SymbolAnalysisContext context, WellKnownTypes wellKnownTypes, RouteUsageCache routeUsageCache, INamedTypeSymbol namedTypeSymbol, List<ActionRoute> actionRoutes)
91+
private static void PopulateActionRoutes(SymbolAnalysisContext context, WellKnownTypes wellKnownTypes, RouteUsageCache routeUsageCache, List<ActionRoute> actionRoutes, IMethodSymbol methodSymbol)
7692
{
77-
foreach (var member in namedTypeSymbol.GetMembers())
93+
// [Route("xxx")] attributes don't have a HTTP method and instead use the HTTP methods of other attributes.
94+
// For example, [HttpGet] + [HttpPost] + [Route("xxx")] means the route "xxx" is combined with the HTTP methods.
95+
var unroutedHttpMethods = GetUnroutedMethodHttpMethods(wellKnownTypes, methodSymbol);
96+
97+
foreach (var attribute in methodSymbol.GetAttributes())
7898
{
79-
if (member is IMethodSymbol methodSymbol &&
80-
MvcDetector.IsAction(methodSymbol, wellKnownTypes))
99+
if (attribute.AttributeClass is null || !wellKnownTypes.IsType(attribute.AttributeClass, RouteAttributeTypes, out var match))
81100
{
82-
// [Route("xxx")] attributes don't have a HTTP method and instead use the HTTP methods of other attributes.
83-
// For example, [HttpGet] + [HttpPost] + [Route("xxx")] means the route "xxx" is combined with the HTTP methods.
84-
var unroutedHttpMethods = GetUnroutedMethodHttpMethods(wellKnownTypes, methodSymbol);
85-
86-
foreach (var attribute in methodSymbol.GetAttributes())
87-
{
88-
if (attribute.AttributeClass is null || !wellKnownTypes.IsType(attribute.AttributeClass, RouteAttributeTypes, out var match))
89-
{
90-
continue;
91-
}
101+
continue;
102+
}
92103

93-
var routeUsage = GetRouteUsageModel(attribute, routeUsageCache, context.CancellationToken);
94-
if (routeUsage is null)
95-
{
96-
continue;
97-
}
104+
var routeUsage = GetRouteUsageModel(attribute, routeUsageCache, context.CancellationToken);
105+
if (routeUsage is null)
106+
{
107+
continue;
108+
}
98109

99-
// [Route] uses unrouted HTTP verb attributes for its HTTP methods.
100-
var methods = match.Value is WellKnownType.Microsoft_AspNetCore_Mvc_RouteAttribute
101-
? unroutedHttpMethods
102-
: ImmutableArray.Create(GetHttpMethod(match.Value)!);
110+
// [Route] uses unrouted HTTP verb attributes for its HTTP methods.
111+
var methods = match.Value is WellKnownType.Microsoft_AspNetCore_Mvc_RouteAttribute
112+
? unroutedHttpMethods
113+
: ImmutableArray.Create(GetHttpMethod(match.Value)!);
103114

104-
actionRoutes.Add(new ActionRoute(methodSymbol, routeUsage, methods));
105-
}
106-
}
115+
actionRoutes.Add(new ActionRoute(methodSymbol, routeUsage, methods));
107116
}
108117
}
109118

@@ -179,4 +188,5 @@ private static ImmutableArray<string> GetUnroutedMethodHttpMethods(WellKnownType
179188
}
180189

181190
private record struct ActionRoute(IMethodSymbol ActionSymbol, RouteUsageModel RouteUsageModel, ImmutableArray<string> HttpMethods);
191+
private record struct AttributeInfo(AttributeData AttributeData, bool IsTargetingMethod);
182192
}

0 commit comments

Comments
 (0)