| | | 1 | | using System.Net; |
| | | 2 | | |
| | | 3 | | namespace ArturRios.Heimdall.WebApi.Security; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// How <see cref="SensitiveDiagnosticsGuard.Evaluate" /> settled an instance's diagnostics. |
| | | 7 | | /// </summary> |
| | | 8 | | public enum SensitiveDiagnosticsDecision |
| | | 9 | | { |
| | | 10 | | /// <summary>The environment turns no sensitive diagnostic on, so there is nothing to decide.</summary> |
| | | 11 | | Off, |
| | | 12 | | |
| | | 13 | | /// <summary>Diagnostics are on and nothing names a public host: a developer machine or the test suite.</summary> |
| | | 14 | | Private, |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// The instance serves a public host and the operator has set |
| | | 18 | | /// <see cref="SensitiveDiagnosticsGuard.AllowVariable" />, so the diagnostics stay on. Start-up |
| | | 19 | | /// warns. |
| | | 20 | | /// </summary> |
| | | 21 | | Allowed, |
| | | 22 | | |
| | | 23 | | /// <summary> |
| | | 24 | | /// The instance serves a public host without the override, so every sensitive diagnostic is |
| | | 25 | | /// turned off. Start-up warns. |
| | | 26 | | /// </summary> |
| | | 27 | | Suppressed |
| | | 28 | | } |
| | | 29 | | |
| | | 30 | | /// <summary> |
| | | 31 | | /// The one decision about the diagnostics that expose data, read by everything that turns one |
| | | 32 | | /// on: EF Core's sensitive data logging and detailed errors (<see cref="DataLogging" />) and the |
| | | 33 | | /// developer exception page (<see cref="DeveloperExceptionPage" />). |
| | | 34 | | /// </summary> |
| | | 35 | | /// <param name="DataLogging">Whether EF Core logs parameter values and adds detail to its errors.</param> |
| | | 36 | | /// <param name="DeveloperExceptionPage">Whether the developer exception page is served.</param> |
| | | 37 | | /// <param name="Decision">How the two were settled.</param> |
| | | 38 | | /// <param name="PublicHosts"> |
| | | 39 | | /// The settings that named a public host, as <c>VARIABLE=host</c>, for the start-up warning. |
| | | 40 | | /// Host names only; no other part of a value. |
| | | 41 | | /// </param> |
| | 923 | 42 | | public sealed record SensitiveDiagnostics( |
| | 1812 | 43 | | bool DataLogging, |
| | 1812 | 44 | | bool DeveloperExceptionPage, |
| | 922 | 45 | | SensitiveDiagnosticsDecision Decision, |
| | 933 | 46 | | IReadOnlyList<string> PublicHosts) |
| | | 47 | | { |
| | | 48 | | /// <summary>Everything off: the safe value before anything has been decided.</summary> |
| | 2 | 49 | | public static readonly SensitiveDiagnostics None = new(false, false, SensitiveDiagnosticsDecision.Off, []); |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | /// <summary> |
| | | 53 | | /// The configuration that says where an instance is reached from: the host names Traefik routes |
| | | 54 | | /// to it, and the public addresses the API already writes into mail and accepts browser calls |
| | | 55 | | /// from. |
| | | 56 | | /// </summary> |
| | | 57 | | public sealed record PublicHostSettings( |
| | | 58 | | string? PublicHost, |
| | | 59 | | string? UiHost, |
| | | 60 | | string? VerificationUrl, |
| | | 61 | | string? PasswordResetUrl, |
| | | 62 | | string? CorsAllowedOrigins) |
| | | 63 | | { |
| | | 64 | | /// <summary>The host name the mobile and desktop clients reach the API under.</summary> |
| | | 65 | | public const string PublicHostVariable = "PUBLIC_HOST"; |
| | | 66 | | |
| | | 67 | | /// <summary>The web UI's host name, under which the API serves its <c>/api/</c> routes.</summary> |
| | | 68 | | public const string UiHostVariable = "UI_HOST"; |
| | | 69 | | |
| | | 70 | | /// <summary>Where a verification link points (the web UI).</summary> |
| | | 71 | | public const string VerificationUrlVariable = "HEIMDALL_EMAIL_VERIFICATION_URL"; |
| | | 72 | | |
| | | 73 | | /// <summary>Where a password-reset link points (the web UI).</summary> |
| | | 74 | | public const string PasswordResetUrlVariable = "HEIMDALL_PASSWORD_RESET_URL"; |
| | | 75 | | |
| | | 76 | | /// <summary>The browser origins allowed to call the API, comma-separated.</summary> |
| | | 77 | | public const string CorsAllowedOriginsVariable = "HEIMDALL_CORS_ALLOWED_ORIGINS"; |
| | | 78 | | |
| | | 79 | | public static PublicHostSettings FromEnvironment() => new( |
| | | 80 | | Environment.GetEnvironmentVariable(PublicHostVariable), |
| | | 81 | | Environment.GetEnvironmentVariable(UiHostVariable), |
| | | 82 | | Environment.GetEnvironmentVariable(VerificationUrlVariable), |
| | | 83 | | Environment.GetEnvironmentVariable(PasswordResetUrlVariable), |
| | | 84 | | Environment.GetEnvironmentVariable(CorsAllowedOriginsVariable)); |
| | | 85 | | |
| | | 86 | | /// <summary> |
| | | 87 | | /// Every setting that names a public host, as <c>VARIABLE=host</c>. The two host variables are |
| | | 88 | | /// read as host names. A URL or an origin counts by its host when it is an absolute |
| | | 89 | | /// <c>http</c> or <c>https</c> URL, and is read as a host name when it has no scheme at all, so |
| | | 90 | | /// <c>ui.example.com/verify</c> is public too. A template placeholder (<c>[[...]]</c>) is not |
| | | 91 | | /// taken for a host. |
| | | 92 | | /// </summary> |
| | | 93 | | public IReadOnlyList<string> PublicHosts() |
| | | 94 | | { |
| | | 95 | | var found = new List<string>(); |
| | | 96 | | |
| | | 97 | | foreach (var (variable, value) in new[] { (PublicHostVariable, PublicHost), (UiHostVariable, UiHost) }) |
| | | 98 | | { |
| | | 99 | | if (SensitiveDiagnosticsGuard.IsPublicHost(value)) |
| | | 100 | | { |
| | | 101 | | found.Add($"{variable}={value!.Trim()}"); |
| | | 102 | | } |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | var urls = new[] { (VerificationUrlVariable, VerificationUrl), (PasswordResetUrlVariable, PasswordResetUrl) } |
| | | 106 | | .Concat((CorsAllowedOrigins ?? string.Empty) |
| | | 107 | | .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) |
| | | 108 | | .Select(origin => (CorsAllowedOriginsVariable, (string?)origin))); |
| | | 109 | | |
| | | 110 | | foreach (var (variable, value) in urls) |
| | | 111 | | { |
| | | 112 | | var trimmed = value?.Trim() ?? string.Empty; |
| | | 113 | | |
| | | 114 | | if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && |
| | | 115 | | (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) |
| | | 116 | | { |
| | | 117 | | if (SensitiveDiagnosticsGuard.IsPublicHost(uri.Host)) |
| | | 118 | | { |
| | | 119 | | found.Add($"{variable}={uri.Host}"); |
| | | 120 | | } |
| | | 121 | | } |
| | | 122 | | else if (!trimmed.Contains("://", StringComparison.Ordinal) && |
| | | 123 | | !trimmed.StartsWith("[[", StringComparison.Ordinal) && |
| | | 124 | | SensitiveDiagnosticsGuard.IsPublicHost(trimmed)) |
| | | 125 | | { |
| | | 126 | | found.Add($"{variable}={SensitiveDiagnosticsGuard.HostOf(trimmed)}"); |
| | | 127 | | } |
| | | 128 | | } |
| | | 129 | | |
| | | 130 | | return found.Distinct().ToList(); |
| | | 131 | | } |
| | | 132 | | } |
| | | 133 | | |
| | | 134 | | /// <summary> |
| | | 135 | | /// NFR-28: decides the diagnostics that expose data — EF Core's sensitive data logging and |
| | | 136 | | /// detailed errors, and the developer exception page. On an instance that serves a public host |
| | | 137 | | /// they are turned off, unless the operator sets <see cref="AllowVariable" /> to <c>true</c>. |
| | | 138 | | /// </summary> |
| | | 139 | | /// <remarks> |
| | | 140 | | /// <para> |
| | | 141 | | /// Those diagnostics are on in every environment but Production: EF writes query parameter |
| | | 142 | | /// values to the log, and the developer exception page answers a failed request with its |
| | | 143 | | /// details. That is what a developer wants on their own machine. On an instance reachable from |
| | | 144 | | /// outside they stay on only when the operator says the instance holds made-up people |
| | | 145 | | /// (Operations & Infrastructure Document §0.2). |
| | | 146 | | /// </para> |
| | | 147 | | /// <para> |
| | | 148 | | /// The instance never refuses to start over this: it starts with the diagnostics off and |
| | | 149 | | /// says so, so an environment that is deployed automatically keeps running. A host is public |
| | | 150 | | /// when any setting in <see cref="PublicHostSettings" /> names anything but a loopback name |
| | | 151 | | /// or address. With nothing set, the instance is taken as private. |
| | | 152 | | /// </para> |
| | | 153 | | /// </remarks> |
| | | 154 | | public static class SensitiveDiagnosticsGuard |
| | | 155 | | { |
| | | 156 | | /// <summary>Set to <c>true</c> to keep sensitive diagnostics on with a public host.</summary> |
| | | 157 | | public const string AllowVariable = "HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS"; |
| | | 158 | | |
| | | 159 | | /// <summary>Reads the host settings and the override from the environment and decides.</summary> |
| | | 160 | | public static SensitiveDiagnostics EvaluateFromEnvironment(bool dataLogging, bool developerExceptionPage) => |
| | | 161 | | Evaluate( |
| | | 162 | | dataLogging, |
| | | 163 | | developerExceptionPage, |
| | | 164 | | PublicHostSettings.FromEnvironment(), |
| | | 165 | | Environment.GetEnvironmentVariable(AllowVariable)); |
| | | 166 | | |
| | | 167 | | /// <summary> |
| | | 168 | | /// Decides what an instance runs with, given what its environment asks for. Split from |
| | | 169 | | /// <see cref="EvaluateFromEnvironment" /> so it can be tested without mutating the process |
| | | 170 | | /// environment the functional suite's hosts read. |
| | | 171 | | /// </summary> |
| | | 172 | | public static SensitiveDiagnostics Evaluate( |
| | | 173 | | bool dataLogging, bool developerExceptionPage, PublicHostSettings hosts, string? allow) |
| | | 174 | | { |
| | | 175 | | if (!dataLogging && !developerExceptionPage) |
| | | 176 | | { |
| | | 177 | | return SensitiveDiagnostics.None; |
| | | 178 | | } |
| | | 179 | | |
| | | 180 | | var publicHosts = hosts.PublicHosts(); |
| | | 181 | | |
| | | 182 | | if (publicHosts.Count == 0) |
| | | 183 | | { |
| | | 184 | | return new SensitiveDiagnostics(dataLogging, developerExceptionPage, SensitiveDiagnosticsDecision.Private, [ |
| | | 185 | | } |
| | | 186 | | |
| | | 187 | | return string.Equals(allow?.Trim(), "true", StringComparison.OrdinalIgnoreCase) |
| | | 188 | | ? new SensitiveDiagnostics(dataLogging, developerExceptionPage, SensitiveDiagnosticsDecision.Allowed, public |
| | | 189 | | : new SensitiveDiagnostics(false, false, SensitiveDiagnosticsDecision.Suppressed, publicHosts); |
| | | 190 | | } |
| | | 191 | | |
| | | 192 | | /// <summary> |
| | | 193 | | /// Whether a host variable names a host other than this machine. Empty is not public; |
| | | 194 | | /// <c>localhost</c>, a name under <c>.localhost</c> and a loopback address are not public; |
| | | 195 | | /// anything else is. A scheme, a port and a path are ignored, so a URL is read as its host. |
| | | 196 | | /// </summary> |
| | | 197 | | public static bool IsPublicHost(string? value) |
| | | 198 | | { |
| | | 199 | | var host = HostOf(value); |
| | | 200 | | |
| | | 201 | | if (host.Length == 0) |
| | | 202 | | { |
| | | 203 | | return false; |
| | | 204 | | } |
| | | 205 | | |
| | | 206 | | if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || |
| | | 207 | | host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)) |
| | | 208 | | { |
| | | 209 | | return false; |
| | | 210 | | } |
| | | 211 | | |
| | | 212 | | return !(IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address)); |
| | | 213 | | } |
| | | 214 | | |
| | | 215 | | /// <summary> |
| | | 216 | | /// The host a host variable or a URL names, without its scheme, port, path, IPv6 brackets or |
| | | 217 | | /// trailing dot; empty when there is none. |
| | | 218 | | /// </summary> |
| | | 219 | | internal static string HostOf(string? value) |
| | | 220 | | { |
| | | 221 | | var host = (value ?? string.Empty).Trim(); |
| | | 222 | | |
| | | 223 | | var scheme = host.IndexOf("://", StringComparison.Ordinal); |
| | | 224 | | |
| | | 225 | | if (scheme >= 0) |
| | | 226 | | { |
| | | 227 | | host = host[(scheme + 3)..]; |
| | | 228 | | } |
| | | 229 | | |
| | | 230 | | var path = host.IndexOf('/'); |
| | | 231 | | |
| | | 232 | | if (path >= 0) |
| | | 233 | | { |
| | | 234 | | host = host[..path]; |
| | | 235 | | } |
| | | 236 | | |
| | | 237 | | if (host.StartsWith('[')) |
| | | 238 | | { |
| | | 239 | | // A bracketed IPv6 address, with or without a port after it. |
| | | 240 | | var close = host.IndexOf(']'); |
| | | 241 | | host = close > 0 ? host[1..close] : host[1..]; |
| | | 242 | | } |
| | | 243 | | else if (host.Count(character => character == ':') == 1) |
| | | 244 | | { |
| | | 245 | | // host:port. More than one colon is a bare IPv6 address, which has no port. |
| | | 246 | | host = host[..host.IndexOf(':')]; |
| | | 247 | | } |
| | | 248 | | |
| | | 249 | | return host.TrimEnd('.'); |
| | | 250 | | } |
| | | 251 | | } |