Review web endpoints without [Authorize] nor [AllowAnonymous]

Rule ND3118 Security

Matches ASP.NET Core controllers, SignalR hubs and legacy MVC / Web API controllers that are not authorized at class level (including inherited classes) and expose public actions carrying neither an authorization attribute ([Authorize], a derived attribute, or any attribute implementing IAuthorizeData) nor an explicit [AllowAnonymous]. The rule stays silent when the application sets a global fallback policy (AuthorizationOptions.FallbackPolicy) or registers a global AuthorizeFilter, since every endpoint is then protected by default.

An endpoint that is anonymous by omission rather than by decision is a classic broken access control (OWASP A01:2021, CWE-306 Missing Authentication for Critical Function).

Flagged Code
public class OrdersController : ControllerBase        // flagged: no [Authorize] on the class
{
   [HttpGet("{id}")]                                    // no [Authorize], no [AllowAnonymous]
   public IActionResult Get(int id) => Ok(_orders.Find(id));

   [HttpGet("status")]
   public IActionResult Status() => Ok("up");
}
Fixed Code
[Authorize]                                          // every action requires authentication
public class OrdersController : ControllerBase
{
   [HttpGet("{id}")]
   public IActionResult Get(int id) => Ok(_orders.Find(id));

   [AllowAnonymous]                                     // explicit decision for the public probe
   [HttpGet("status")]
   public IActionResult Status() => Ok("up");
}

How to fix

Tag the controller (or its base class) with [Authorize] and opt out with [AllowAnonymous] on the few public actions, or configure a fallback policy so that everything requires authentication by default: builder.Services.AddAuthorization(o => o.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()). Public helper methods that are not actions should be non-public or tagged [NonAction].

CQLinq source

This rule is written in CQLinq, C# LINQ over the NDepend code model. It ships with every new NDepend project: you can read it, adapt it, or use it as a starting point for your own custom rules.

// <Name>Review web endpoints without [Authorize] nor [AllowAnonymous]</Name>
// <Id>ND3118:ReviewWebEndpointsWithoutAuthorizeNorAllowAnonymous</Id>
warnif count > 0

// Third-party types are part of the code model only when the application uses them.
let controllerBaseTypes = ThirdParty.Types.WithFullNameIn(
  "Microsoft.AspNetCore.Mvc.ControllerBase",
  "Microsoft.AspNetCore.SignalR.Hub",
  "System.Web.Mvc.Controller",
  "System.Web.Http.ApiController").ToArray()
where controllerBaseTypes.Any()

// A fallback policy or a global AuthorizeFilter protects every endpoint: nothing to report.
let appHasGlobalAuthorization = Application.Methods.Any(m =>
   m.IsUsing("Microsoft.AspNetCore.Authorization.AuthorizationOptions.set_FallbackPolicy(AuthorizationPolicy)".AllowNoMatch()) ||
   m.CreateA("Microsoft.AspNetCore.Mvc.Authorization.AuthorizeFilter".AllowNoMatch()) ||
   m.IsUsing("System.Web.Mvc.AuthorizeAttribute..ctor()".AllowNoMatch()))
where !appHasGlobalAuthorization

// [Authorize], attributes deriving from it, and any attribute implementing IAuthorizeData (custom policies)
let authorizeAttrs = ThirdParty.Types.WithFullNameIn(
  "Microsoft.AspNetCore.Authorization.AuthorizeAttribute",
  "System.Web.Mvc.AuthorizeAttribute",
  "System.Web.Http.AuthorizeAttribute").ToArray()
let authorizeDataInterfaces = ThirdParty.Types.WithFullName("Microsoft.AspNetCore.Authorization.IAuthorizeData").ToArray()
let authorizeAttrClasses = authorizeAttrs
   .Concat(Types.ThatDeriveFromAny(authorizeAttrs))
   .Concat(Types.ThatImplementAny(authorizeDataInterfaces))
   .ToHashSetEx()

let anonymousAttrs = ThirdParty.Types.WithFullNameIn(
  "Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute",
  "System.Web.Mvc.AllowAnonymousAttribute",
  "System.Web.Http.AllowAnonymousAttribute").ToArray()
let allowAnonymousInterfaces = ThirdParty.Types.WithFullName("Microsoft.AspNetCore.Authorization.IAllowAnonymous").ToArray()
let anonymousAttrClasses = anonymousAttrs
   .Concat(Types.ThatDeriveFromAny(anonymousAttrs))
   .Concat(Types.ThatImplementAny(allowAnonymousInterfaces))
   .ToHashSetEx()

let nonActionAttrs = ThirdParty.Types.WithFullNameIn("Microsoft.AspNetCore.Mvc.NonActionAttribute", "System.Web.Mvc.NonActionAttribute").ToHashSetEx()

let isAuthorized = new Func<IAttributeTarget, bool>(x => x.AttributeClassesThatTagMe.Any(a => authorizeAttrClasses.Contains(a)))
let isAnonymous  = new Func<IAttributeTarget, bool>(x => x.AttributeClassesThatTagMe.Any(a => anonymousAttrClasses.Contains(a)))

from t in Application.Types.ThatDeriveFromAny(controllerBaseTypes)
where !t.IsAbstract && !t.IsGeneratedByCompiler
// An attribute on the class or on any base class covers every action.
let classChain = t.BaseClasses.Concat(new[] { t }).ToArray()
where !classChain.Any(c => isAuthorized(c)) && !classChain.Any(c => isAnonymous(c))

let unprotectedActions = t.Methods.Where(m =>
      m.IsPublic && !m.IsStatic && !m.IsConstructor && !m.IsGeneratedByCompiler &&
     !m.IsPropertyGetter && !m.IsPropertySetter && !m.IsEventAdder && !m.IsEventRemover &&
     !m.OverriddensBase.Any(mb => mb.IsThirdParty) &&                   // OnActionExecuting(), Dispose(), ToString()...
     !m.AttributeClassesThatTagMe.Any(a => nonActionAttrs.Contains(a)) &&
     !isAuthorized(m) && !isAnonymous(m)).ToArray()
where unprotectedActions.Length > 0

select new {
  t,
  unprotectedActions,
  Debt = (5 * unprotectedActions.Length).ToMinutes().ToDebt(),
  Severity = Severity.High
}

//<Expl>
// The controller *{0}* has no *[Authorize]* on its class chain and
// exposes action(s) *{1}* tagged with neither *[Authorize]* nor *[AllowAnonymous]*:
// they answer any unauthenticated request.
//</Expl>

//<Desc>
// Matches ASP.NET Core controllers, SignalR hubs and legacy MVC / Web API
// controllers that are not authorized at class level (including inherited
// classes) and expose public actions carrying neither an authorization
// attribute (*[Authorize]*, a derived attribute, or any attribute
// implementing *IAuthorizeData*) nor an explicit *[AllowAnonymous]*.
// The rule stays silent when the application sets a global fallback
// policy (*AuthorizationOptions.FallbackPolicy*) or registers a global
// *AuthorizeFilter*, since every endpoint is then protected by default.
//
// An endpoint that is anonymous by omission rather than by decision is a
// classic broken access control (OWASP A01:2021, CWE-306 *Missing
// Authentication for Critical Function*).
//
// **Flagged Code**
// ```csharp
// public class OrdersController : ControllerBase        // flagged: no [Authorize] on the class
// {
//    [HttpGet("{id}")]                                    // no [Authorize], no [AllowAnonymous]
//    public IActionResult Get(int id) => Ok(_orders.Find(id));
//
//    [HttpGet("status")]
//    public IActionResult Status() => Ok("up");
// }
// ```
//
// **Fixed Code**
// ```csharp
// [Authorize]                                          // every action requires authentication
// public class OrdersController : ControllerBase
// {
//    [HttpGet("{id}")]
//    public IActionResult Get(int id) => Ok(_orders.Find(id));
//
//    [AllowAnonymous]                                     // explicit decision for the public probe
//    [HttpGet("status")]
//    public IActionResult Status() => Ok("up");
// }
// ```
//</Desc>

//<HowToFix>
// Tag the controller (or its base class) with *[Authorize]* and opt out
// with *[AllowAnonymous]* on the few public actions, or configure a
// fallback policy so that everything requires authentication by default:
// *builder.Services.AddAuthorization(o => o.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())*.
// Public helper methods that are not actions should be non-public or
// tagged *[NonAction]*.
//</HowToFix>

Security: all 39 items