| | | 1 | | using ProjectTemplate.Web.Authentication.Options; |
| | | 2 | | using ProjectTemplate.Web.Options; |
| | | 3 | | |
| | | 4 | | namespace ProjectTemplate.Web.Extensions; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Provides startup-only diagnostics for security-relevant supported deployment postures. |
| | | 8 | | /// </summary> |
| | | 9 | | public static class StartupSecurityPostureExtensions |
| | | 10 | | { |
| | | 11 | | private const string _authenticationEnabledConfigurationKey = |
| | | 12 | | ApplicationAuthenticationOptions.SectionName + ":Enabled"; |
| | | 13 | | |
| | | 14 | | private const string _anonymousHealthEndpoints = |
| | | 15 | | "/health, /health/ready, /health/live"; |
| | | 16 | | |
| | | 17 | | private const string _dataProtectionKeyRingPathConfigurationKey = |
| | | 18 | | ApplicationDataProtectionOptions.SectionName + ":" + nameof(ApplicationDataProtectionOptions.KeyRingPath); |
| | | 19 | | |
| | | 20 | | private const string _dataProtectionKeyEncryptionCertificatePathConfigurationKey = |
| | | 21 | | ApplicationDataProtectionOptions.SectionName + ":" + nameof(ApplicationDataProtectionOptions.KeyEncryptionCertif |
| | | 22 | | |
| | | 23 | | private const string _allowedHostsConfigurationKey = "AllowedHosts"; |
| | | 24 | | |
| | 1 | 25 | | private static readonly Action<ILogger, string, Exception?> _logPermissiveAllowedHosts = |
| | 1 | 26 | | LoggerMessage.Define<string>( |
| | 1 | 27 | | LogLevel.Warning, |
| | 1 | 28 | | new EventId(1005, "PermissiveAllowedHosts"), |
| | 1 | 29 | | "Security posture: {ConfigurationKey} allows every host, so ASP.NET Core host filtering accepts any Host " + |
| | 1 | 30 | | "header. Set it to the public host names this deployment serves, such as \"app.example.com;www.example.com\" |
| | | 31 | | |
| | 1 | 32 | | private static readonly Action<ILogger, string, string, Exception?> _logDataProtectionKeyRingUnderContentRoot = |
| | 1 | 33 | | LoggerMessage.Define<string, string>( |
| | 1 | 34 | | LogLevel.Warning, |
| | 1 | 35 | | new EventId(1003, "DataProtectionKeyRingRelativePath"), |
| | 1 | 36 | | "Security posture: {ConfigurationKey} is the relative path '{KeyRingPath}', so the Data Protection key ring |
| | 1 | 37 | | "is stored under the application content root. In containers and orchestrated deployments that location is " |
| | 1 | 38 | | "usually replaced with the application, which invalidates authentication cookies and antiforgery tokens. " + |
| | 1 | 39 | | "Configure an absolute path on durable, access-restricted storage shared by every replica."); |
| | | 40 | | |
| | 1 | 41 | | private static readonly Action<ILogger, string, Exception?> _logDataProtectionKeysNotEncrypted = |
| | 1 | 42 | | LoggerMessage.Define<string>( |
| | 1 | 43 | | LogLevel.Warning, |
| | 1 | 44 | | new EventId(1004, "DataProtectionKeysNotEncryptedAtRest"), |
| | 1 | 45 | | "Security posture: {ConfigurationKey} is not set, so Data Protection key-ring files are not encrypted by the |
| | 1 | 46 | | "application. On Linux and macOS they are written in plain text; on Windows they are protected with DPAPI fo |
| | 1 | 47 | | "the current user and cannot be shared across machines. Configure a key-encryption certificate or confirm " |
| | 1 | 48 | | "that storage-level encryption and access controls protect the key ring."); |
| | | 49 | | |
| | 1 | 50 | | private static readonly Action<ILogger, string, Exception?> _logAuthenticationDisabled = |
| | 1 | 51 | | LoggerMessage.Define<string>( |
| | 1 | 52 | | LogLevel.Warning, |
| | 1 | 53 | | new EventId(1001, "AuthenticationDisabled"), |
| | 1 | 54 | | "Security posture: authentication is intentionally disabled by {ConfigurationKey}. " + |
| | 1 | 55 | | "This is a supported configuration, not an authentication framework failure. " + |
| | 1 | 56 | | "Review deployment exposure and authorization expectations before production use."); |
| | | 57 | | |
| | 1 | 58 | | private static readonly Action<ILogger, string, Exception?> _logAnonymousProductionHealthEndpoints = |
| | 1 | 59 | | LoggerMessage.Define<string>( |
| | 1 | 60 | | LogLevel.Warning, |
| | 1 | 61 | | new EventId(1002, "AnonymousProductionHealthEndpoints"), |
| | 1 | 62 | | "Security posture: health endpoints {HealthEndpoints} are intentionally mapped with anonymous access in Prod |
| | 1 | 63 | | "Anonymous probes are supported for infrastructure health checks; confirm reverse-proxy, ingress, firewall, |
| | 1 | 64 | | "or service-mesh routing limits external reachability as intended."); |
| | | 65 | | |
| | | 66 | | /// <summary> |
| | | 67 | | /// Emits structured startup diagnostics for supported security postures that require |
| | | 68 | | /// operator awareness. |
| | | 69 | | /// </summary> |
| | | 70 | | /// <param name="app">The application whose startup posture is being reported.</param> |
| | | 71 | | /// <returns>The original <see cref="WebApplication"/> for chaining.</returns> |
| | | 72 | | public static WebApplication LogApplicationSecurityPosture(this WebApplication app) |
| | | 73 | | { |
| | 91 | 74 | | ArgumentNullException.ThrowIfNull(app); |
| | | 75 | | |
| | 91 | 76 | | LogApplicationSecurityPosture(app.Logger, app.Configuration, app.Environment); |
| | | 77 | | |
| | 91 | 78 | | return app; |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | /// <summary> |
| | | 82 | | /// Emits structured startup diagnostics using the supplied application services. |
| | | 83 | | /// This overload keeps the posture rules independently testable. |
| | | 84 | | /// </summary> |
| | | 85 | | /// <param name="logger">The startup logger.</param> |
| | | 86 | | /// <param name="configuration">The application configuration.</param> |
| | | 87 | | /// <param name="environment">The host environment.</param> |
| | | 88 | | public static void LogApplicationSecurityPosture( |
| | | 89 | | ILogger logger, |
| | | 90 | | IConfiguration configuration, |
| | | 91 | | IHostEnvironment environment) |
| | | 92 | | { |
| | 101 | 93 | | ArgumentNullException.ThrowIfNull(logger); |
| | 101 | 94 | | ArgumentNullException.ThrowIfNull(configuration); |
| | 101 | 95 | | ArgumentNullException.ThrowIfNull(environment); |
| | | 96 | | |
| | 101 | 97 | | bool authenticationEnabled = configuration.GetValue<bool>( |
| | 101 | 98 | | _authenticationEnabledConfigurationKey); |
| | | 99 | | |
| | 101 | 100 | | if (!authenticationEnabled) |
| | | 101 | | { |
| | 2 | 102 | | _logAuthenticationDisabled( |
| | 2 | 103 | | logger, |
| | 2 | 104 | | _authenticationEnabledConfigurationKey, |
| | 2 | 105 | | null); |
| | | 106 | | } |
| | | 107 | | |
| | 101 | 108 | | if (environment.IsProduction()) |
| | | 109 | | { |
| | 2 | 110 | | _logAnonymousProductionHealthEndpoints( |
| | 2 | 111 | | logger, |
| | 2 | 112 | | _anonymousHealthEndpoints, |
| | 2 | 113 | | null); |
| | | 114 | | } |
| | | 115 | | |
| | 101 | 116 | | if (!environment.IsDevelopment()) |
| | | 117 | | { |
| | 96 | 118 | | LogHostFilteringPosture(logger, configuration); |
| | 96 | 119 | | LogDataProtectionPosture(logger, configuration); |
| | | 120 | | } |
| | 101 | 121 | | } |
| | | 122 | | |
| | | 123 | | private static void LogHostFilteringPosture(ILogger logger, IConfiguration configuration) |
| | | 124 | | { |
| | 96 | 125 | | string? allowedHosts = configuration[_allowedHostsConfigurationKey]?.Trim(); |
| | | 126 | | |
| | | 127 | | // An absent value and "*" both allow every Host header, because host filtering defaults to allowing all hosts. |
| | 96 | 128 | | bool allowsEveryHost = string.IsNullOrEmpty(allowedHosts) || |
| | 96 | 129 | | allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) |
| | 96 | 130 | | .Any(host => string.Equals(host, "*", StringComparison.Ordinal)); |
| | | 131 | | |
| | 96 | 132 | | if (allowsEveryHost) |
| | | 133 | | { |
| | 93 | 134 | | _logPermissiveAllowedHosts(logger, _allowedHostsConfigurationKey, null); |
| | | 135 | | } |
| | 96 | 136 | | } |
| | | 137 | | |
| | | 138 | | private static void LogDataProtectionPosture(ILogger logger, IConfiguration configuration) |
| | | 139 | | { |
| | 96 | 140 | | string keyRingPath = configuration[_dataProtectionKeyRingPathConfigurationKey]?.Trim() is { Length: > 0 } config |
| | 96 | 141 | | ? configuredPath |
| | 96 | 142 | | : new ApplicationDataProtectionOptions().KeyRingPath; |
| | | 143 | | |
| | 96 | 144 | | if (!Path.IsPathFullyQualified(keyRingPath)) |
| | | 145 | | { |
| | 93 | 146 | | _logDataProtectionKeyRingUnderContentRoot( |
| | 93 | 147 | | logger, |
| | 93 | 148 | | _dataProtectionKeyRingPathConfigurationKey, |
| | 93 | 149 | | keyRingPath, |
| | 93 | 150 | | null); |
| | | 151 | | } |
| | | 152 | | |
| | 96 | 153 | | if (string.IsNullOrWhiteSpace(configuration[_dataProtectionKeyEncryptionCertificatePathConfigurationKey])) |
| | | 154 | | { |
| | 92 | 155 | | _logDataProtectionKeysNotEncrypted( |
| | 92 | 156 | | logger, |
| | 92 | 157 | | _dataProtectionKeyEncryptionCertificatePathConfigurationKey, |
| | 92 | 158 | | null); |
| | | 159 | | } |
| | 96 | 160 | | } |
| | | 161 | | } |