Skip to content

Commit daab261

Browse files
authored
Add header dictionary analyzer and fixer (dotnet#44123)
1 parent 41bed3f commit daab261

15 files changed

+799
-207
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,13 @@ internal static class DiagnosticDescriptors
115115
DiagnosticSeverity.Warning,
116116
isEnabledByDefault: true,
117117
helpLinkUri: "https://aka.ms/aspnet/analyzers");
118+
119+
internal static readonly DiagnosticDescriptor UseHeaderDictionaryPropertiesInsteadOfIndexer = new(
120+
"ASP0015",
121+
"Suggest using IHeaderDictionary properties",
122+
"The header '{0}' can be accessed using the {1} property",
123+
"Usage",
124+
DiagnosticSeverity.Info,
125+
isEnabledByDefault: true,
126+
helpLinkUri: "https://aka.ms/aspnet/analyzers");
118127
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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.Collections.Immutable;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.Diagnostics;
9+
using Microsoft.CodeAnalysis.Operations;
10+
11+
namespace Microsoft.AspNetCore.Analyzers.Http;
12+
13+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
14+
public partial class HeaderDictionaryIndexerAnalyzer : DiagnosticAnalyzer
15+
{
16+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.UseHeaderDictionaryPropertiesInsteadOfIndexer);
17+
18+
public override void Initialize(AnalysisContext context)
19+
{
20+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
21+
context.EnableConcurrentExecution();
22+
context.RegisterOperationAction(context =>
23+
{
24+
var propertyReference = (IPropertyReferenceOperation)context.Operation;
25+
var property = propertyReference.Property;
26+
27+
// Check if property is the indexer on IHeaderDictionary, e.g. headers["content-type"]
28+
if (property.IsIndexer &&
29+
property.Parameters.Length == 1 &&
30+
property.Parameters[0].Type.SpecialType == SpecialType.System_String &&
31+
IsIHeadersDictionaryType(property.ContainingType))
32+
{
33+
// Get the indexer string argument.
34+
if (propertyReference.Arguments.Length == 1 &&
35+
propertyReference.Arguments[0].Value is ILiteralOperation literalOperation &&
36+
literalOperation.ConstantValue.Value is string indexerValue)
37+
{
38+
// Check that the header has a matching property on IHeaderDictionary.
39+
if (PropertyMapping.TryGetValue(indexerValue, out var propertyName))
40+
{
41+
AddDiagnosticWarning(context, propertyReference.Syntax.GetLocation(), indexerValue, propertyName);
42+
}
43+
}
44+
}
45+
}, OperationKind.PropertyReference);
46+
}
47+
48+
private static bool IsIHeadersDictionaryType(INamedTypeSymbol type)
49+
{
50+
// Only IHeaderDictionary is valid. Types like HeaderDictionary, which implement IHeaderDictionary,
51+
// can't access header properties unless cast as IHeaderDictionary.
52+
return type is
53+
{
54+
Name: "IHeaderDictionary",
55+
ContainingNamespace:
56+
{
57+
Name: "Http",
58+
ContainingNamespace:
59+
{
60+
Name: "AspNetCore",
61+
ContainingNamespace:
62+
{
63+
Name: "Microsoft",
64+
ContainingNamespace:
65+
{
66+
IsGlobalNamespace: true
67+
}
68+
}
69+
}
70+
}
71+
};
72+
}
73+
74+
// Internal for unit tests
75+
// Note that this dictionary should be kept in sync with properties in IHeaderDictionary.Keyed.cs
76+
// Key = property name, Value = header name
77+
internal static readonly Dictionary<string, string> PropertyMapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
78+
{
79+
["Accept"] = "Accept",
80+
["Accept-Charset"] = "AcceptCharset",
81+
["Accept-Encoding"] = "AcceptEncoding",
82+
["Accept-Language"] = "AcceptLanguage",
83+
["Accept-Ranges"] = "AcceptRanges",
84+
["Access-Control-Allow-Credentials"] = "AccessControlAllowCredentials",
85+
["Access-Control-Allow-Headers"] = "AccessControlAllowHeaders",
86+
["Access-Control-Allow-Methods"] = "AccessControlAllowMethods",
87+
["Access-Control-Allow-Origin"] = "AccessControlAllowOrigin",
88+
["Access-Control-Expose-Headers"] = "AccessControlExposeHeaders",
89+
["Access-Control-Max-Age"] = "AccessControlMaxAge",
90+
["Access-Control-Request-Headers"] = "AccessControlRequestHeaders",
91+
["Access-Control-Request-Method"] = "AccessControlRequestMethod",
92+
["Age"] = "Age",
93+
["Allow"] = "Allow",
94+
["Alt-Svc"] = "AltSvc",
95+
["Authorization"] = "Authorization",
96+
["baggage"] = "Baggage",
97+
["Cache-Control"] = "CacheControl",
98+
["Connection"] = "Connection",
99+
["Content-Disposition"] = "ContentDisposition",
100+
["Content-Encoding"] = "ContentEncoding",
101+
["Content-Language"] = "ContentLanguage",
102+
["Content-Location"] = "ContentLocation",
103+
["Content-MD5"] = "ContentMD5",
104+
["Content-Range"] = "ContentRange",
105+
["Content-Security-Policy"] = "ContentSecurityPolicy",
106+
["Content-Security-Policy-Report-Only"] = "ContentSecurityPolicyReportOnly",
107+
["Content-Type"] = "ContentType",
108+
["Correlation-Context"] = "CorrelationContext",
109+
["Cookie"] = "Cookie",
110+
["Date"] = "Date",
111+
["ETag"] = "ETag",
112+
["Expires"] = "Expires",
113+
["Expect"] = "Expect",
114+
["From"] = "From",
115+
["Grpc-Accept-Encoding"] = "GrpcAcceptEncoding",
116+
["Grpc-Encoding"] = "GrpcEncoding",
117+
["Grpc-Message"] = "GrpcMessage",
118+
["Grpc-Status"] = "GrpcStatus",
119+
["Grpc-Timeout"] = "GrpcTimeout",
120+
["Host"] = "Host",
121+
["Keep-Alive"] = "KeepAlive",
122+
["If-Match"] = "IfMatch",
123+
["If-Modified-Since"] = "IfModifiedSince",
124+
["If-None-Match"] = "IfNoneMatch",
125+
["If-Range"] = "IfRange",
126+
["If-Unmodified-Since"] = "IfUnmodifiedSince",
127+
["Last-Modified"] = "LastModified",
128+
["Link"] = "Link",
129+
["Location"] = "Location",
130+
["Max-Forwards"] = "MaxForwards",
131+
["Origin"] = "Origin",
132+
["Pragma"] = "Pragma",
133+
["Proxy-Authenticate"] = "ProxyAuthenticate",
134+
["Proxy-Authorization"] = "ProxyAuthorization",
135+
["Proxy-Connection"] = "ProxyConnection",
136+
["Range"] = "Range",
137+
["Referer"] = "Referer",
138+
["Retry-After"] = "RetryAfter",
139+
["Request-Id"] = "RequestId",
140+
["Sec-WebSocket-Accept"] = "SecWebSocketAccept",
141+
["Sec-WebSocket-Key"] = "SecWebSocketKey",
142+
["Sec-WebSocket-Protocol"] = "SecWebSocketProtocol",
143+
["Sec-WebSocket-Version"] = "SecWebSocketVersion",
144+
["Sec-WebSocket-Extensions"] = "SecWebSocketExtensions",
145+
["Server"] = "Server",
146+
["Set-Cookie"] = "SetCookie",
147+
["Strict-Transport-Security"] = "StrictTransportSecurity",
148+
["TE"] = "TE",
149+
["Trailer"] = "Trailer",
150+
["Transfer-Encoding"] = "TransferEncoding",
151+
["Translate"] = "Translate",
152+
["traceparent"] = "TraceParent",
153+
["tracestate"] = "TraceState",
154+
["Upgrade"] = "Upgrade",
155+
["Upgrade-Insecure-Requests"] = "UpgradeInsecureRequests",
156+
["User-Agent"] = "UserAgent",
157+
["Vary"] = "Vary",
158+
["Via"] = "Via",
159+
["Warning"] = "Warning",
160+
["WWW-Authenticate"] = "WWWAuthenticate",
161+
["X-Content-Type-Options"] = "XContentTypeOptions",
162+
["X-Frame-Options"] = "XFrameOptions",
163+
["X-Powered-By"] = "XPoweredBy",
164+
["X-Requested-With"] = "XRequestedWith",
165+
["X-UA-Compatible"] = "XUACompatible",
166+
["X-XSS-Protection"] = "XXSSProtection",
167+
};
168+
169+
private static void AddDiagnosticWarning(OperationAnalysisContext context, Location location, string headerName, string propertyName)
170+
{
171+
var propertiesBuilder = ImmutableDictionary.CreateBuilder<string, string>();
172+
propertiesBuilder.Add("HeaderName", headerName);
173+
propertiesBuilder.Add("ResolvedPropertyName", propertyName);
174+
175+
context.ReportDiagnostic(Diagnostic.Create(
176+
DiagnosticDescriptors.UseHeaderDictionaryPropertiesInsteadOfIndexer,
177+
location,
178+
propertiesBuilder.ToImmutable(),
179+
headerName,
180+
propertyName));
181+
}
182+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.Collections.Immutable;
5+
using System.Composition;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.CodeActions;
10+
using Microsoft.CodeAnalysis.CodeFixes;
11+
using Microsoft.CodeAnalysis.CSharp;
12+
using Microsoft.CodeAnalysis.CSharp.Syntax;
13+
14+
namespace Microsoft.AspNetCore.Analyzers.Http.Fixers;
15+
16+
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
17+
public class HeaderDictionaryIndexerFixer : CodeFixProvider
18+
{
19+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticDescriptors.UseHeaderDictionaryPropertiesInsteadOfIndexer.Id);
20+
21+
public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
22+
23+
public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
24+
{
25+
foreach (var diagnostic in context.Diagnostics)
26+
{
27+
if (diagnostic.Properties.TryGetValue("HeaderName", out var headerName) &&
28+
diagnostic.Properties.TryGetValue("ResolvedPropertyName", out var resolvedPropertyName))
29+
{
30+
var title = $"Access header '{headerName}' with {resolvedPropertyName} property";
31+
context.RegisterCodeFix(
32+
CodeAction.Create(title,
33+
cancellationToken => FixHeaderDictionaryIndexer(diagnostic, context.Document, resolvedPropertyName, cancellationToken),
34+
equivalenceKey: title),
35+
diagnostic);
36+
}
37+
}
38+
39+
return Task.CompletedTask;
40+
}
41+
42+
private static async Task<Document> FixHeaderDictionaryIndexer(Diagnostic diagnostic, Document document, string resolvedPropertyName, CancellationToken cancellationToken)
43+
{
44+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
45+
46+
if (root == null)
47+
{
48+
return document;
49+
}
50+
51+
var param = root.FindNode(diagnostic.Location.SourceSpan);
52+
if (param is ArgumentSyntax argumentSyntax)
53+
{
54+
param = argumentSyntax.Expression;
55+
}
56+
57+
if (param is ElementAccessExpressionSyntax { Expression: { } expression } elementAccessExpressionSyntax)
58+
{
59+
var newExpression = SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, expression, SyntaxFactory.IdentifierName(resolvedPropertyName));
60+
return document.WithSyntaxRoot(root.ReplaceNode(elementAccessExpressionSyntax, newExpression));
61+
}
62+
63+
return document;
64+
}
65+
}

0 commit comments

Comments
 (0)