using System.Reflection; using Bit.Api.AdminConsole.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Xunit; namespace Bit.Api.Test.Utilities; public static class ControllerAuthorizationTestHelpers { private static readonly Type[] _httpMethodAttributes = [ typeof(HttpGetAttribute), typeof(HttpPostAttribute), typeof(HttpPutAttribute), typeof(HttpDeleteAttribute), typeof(HttpPatchAttribute), typeof(HttpHeadAttribute), typeof(HttpOptionsAttribute) ]; /// /// Asserts that a controller follows the two-layer authorization pattern required by Bitwarden. /// /// The controller type to validate. /// /// This enforces two requirements: /// /// /// /// Class-level requirement: The controller MUST have a class-level [Authorize] attribute. /// /// /// /// /// Method-level requirement: Every HTTP action method MUST have either: /// /// Any custom AuthorizeAttribute implementation (e.g., [Authorize<TRequirement>]) for protected endpoints, OR /// [AllowAnonymous] for intentionally public endpoints /// /// /// /// /// /// This ensures that every route is explicitly decorated with authorization, preventing accidental /// exposure of endpoints. The class-level [Authorize] alone is necessary but not sufficient. /// Note that the base [Authorize] attribute is not accepted at the method level. /// /// /// /// Thrown when the controller is missing class-level authorization, has no HTTP methods, /// or has HTTP methods without explicit method-level authorization. /// /// /// /// [Fact] /// public void AllActionMethodsHaveAuthorization() /// { /// ControllerAuthorizationTestHelpers.AssertAllHttpMethodsHaveAuthorization( /// typeof(MyController)); /// } /// /// public static void AssertAllHttpMethodsHaveAuthorization(Type controllerType) { var methods = controllerType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); var httpActionMethods = methods .Where(HasHttpMethodAttribute) .ToList(); if (httpActionMethods.Count == 0) { Assert.Fail($"Controller {controllerType.Name} has no HTTP action methods."); } // REQUIRE class-level [Authorize] var classHasAuthorization = HasAuthorizeAttribute(controllerType); if (!classHasAuthorization) { Assert.Fail( $"Controller {controllerType.Name} is missing required class-level [Authorize] attribute.\n" + $"All controllers must have [Authorize] at the class level as a baseline security measure."); } // REQUIRE each method to have explicit authorization var unauthorizedMethods = new List(); foreach (var method in httpActionMethods) { // Only check for custom [Authorize] (not base [Authorize]) var methodHasCustomAuthorize = HasCustomAuthorizeAttribute(method); var methodHasAllowAnonymous = HasAllowAnonymousAttribute(method); var methodHasNoopAuthorize = HasNoopAuthorizeAttribute(method); // Method must have EITHER [Authorize] OR [AllowAnonymous] OR [NoopAuthorize] var hasAuthorizationAttribute = methodHasCustomAuthorize || methodHasAllowAnonymous || methodHasNoopAuthorize; if (!hasAuthorizationAttribute) { var httpAttributes = string.Join(", ", method.GetCustomAttributes() .Where(a => _httpMethodAttributes.Contains(a.GetType())) .Select(a => $"[{a.GetType().Name.Replace("Attribute", "")}]")); unauthorizedMethods.Add($"{method.Name} ({httpAttributes})"); } } if (unauthorizedMethods.Count != 0) { var methodList = string.Join("\n - ", unauthorizedMethods); Assert.Fail( $"Controller {controllerType.Name} has {unauthorizedMethods.Count} HTTP action method(s) without method-level authorization:\n" + $" - {methodList}\n\n" + $"Each HTTP action method must be explicitly decorated with:\n" + $" - [Authorize] for protected endpoints, OR\n" + $" - [AllowAnonymous] for intentionally public endpoints, OR\n" + $" - [NoopAuthorize] to explicitly document that no additional authorization is required\n\n" + $"Note: Class-level [Authorize] is required but not sufficient. Every route must be explicitly decorated."); } } private static bool HasHttpMethodAttribute(MethodInfo method) { return method.GetCustomAttributes() .Any(attr => _httpMethodAttributes.Contains(attr.GetType())); } /// /// Checks if a type or method has any [Authorize] attribute (including subclasses). /// Used for class-level checks. /// private static bool HasAuthorizeAttribute(MemberInfo member) { return member.GetCustomAttributes() .Any(attr => attr.GetType().IsAssignableTo(typeof(AuthorizeAttribute))); } /// /// Checks if a method has a custom (subclassed) [Authorize] attribute. /// Does NOT match the base [Authorize] attribute. /// Used for method-level checks. /// /// /// We don't match the base [Authorize] attribute because we don't currently use this /// for role-based checks on methods, so it is unlikely to indicate a /// proper authorization check. This is based on current practice only and could be /// changed in the future if our practice changes. /// private static bool HasCustomAuthorizeAttribute(MethodInfo method) { return method.GetCustomAttributes() .Select(attr => attr.GetType()) .Any(attrType => attrType.IsSubclassOf(typeof(AuthorizeAttribute))); } private static bool HasAllowAnonymousAttribute(MethodInfo method) { return method.GetCustomAttribute() != null; } private static bool HasNoopAuthorizeAttribute(MethodInfo method) { return method.GetCustomAttribute() != null; } }