< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.SensitiveDiagnostics
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/SensitiveDiagnosticsGuard.cs
Line coverage
100%
Covered lines: 6
Uncovered lines: 0
Coverable lines: 6
Total lines: 251
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 6
Fully covered methods: 6
Total methods: 6
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_DataLogging()100%11100%
get_DeveloperExceptionPage()100%11100%
get_Decision()100%11100%
get_PublicHosts()100%11100%
.cctor()100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/SensitiveDiagnosticsGuard.cs

#LineLine coverage
 1using System.Net;
 2
 3namespace ArturRios.Heimdall.WebApi.Security;
 4
 5/// <summary>
 6///     How <see cref="SensitiveDiagnosticsGuard.Evaluate" /> settled an instance's diagnostics.
 7/// </summary>
 8public 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>
 92342public sealed record SensitiveDiagnostics(
 181243    bool DataLogging,
 181244    bool DeveloperExceptionPage,
 92245    SensitiveDiagnosticsDecision Decision,
 93346    IReadOnlyList<string> PublicHosts)
 47{
 48    /// <summary>Everything off: the safe value before anything has been decided.</summary>
 249    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>
 57public 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 &amp; 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>
 154public 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}