Read the Heimdall app secret from Heimdall__AppSecretFile; env value deprecated

The file wins, is read once when the options are built, and a set-but-missing,
unreadable or empty file stops startup in every environment without naming the
secret. Heimdall__AppSecret still works and logs a deprecation warning.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-12 13:24:59 +00:00
commit 95d2e8d262
5 changed files with 201 additions and 2 deletions

View file

@ -14,7 +14,11 @@ public sealed class HeimdallOptions
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
public string AppSecret { get; init; } = string.Empty;
/// <summary>Deprecated: rides in the process environment. Use <see cref="AppSecretFile"/>; the file wins when both are set.</summary>
public string AppSecret { get; set; } = string.Empty;
/// <summary>Path of a file holding the app secret (systemd <c>LoadCredential</c> style: <c>%d/heimdall-app-secret</c>).</summary>
public string AppSecretFile { get; init; } = string.Empty;
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
@ -39,4 +43,37 @@ public sealed class HeimdallOptions
IsConfigured &&
EnablePatronSupportIntake &&
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
/// <summary>
/// Resolves <see cref="AppSecret"/> once, when the options are built. A set <see cref="AppSecretFile"/> must be a
/// readable, non-empty file, or startup fails; the message names the option and path, never a secret.
/// </summary>
public static void ResolveAppSecret(HeimdallOptions options, ILogger logger)
{
if (!string.IsNullOrWhiteSpace(options.AppSecret))
{
logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set.");
}
if (!string.IsNullOrWhiteSpace(options.AppSecretFile))
{
string secret;
try
{
secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n');
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
throw new InvalidOperationException(
$"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name}).");
}
if (string.IsNullOrWhiteSpace(secret))
{
throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty.");
}
options.AppSecret = secret;
}
}
}

View file

@ -22,6 +22,9 @@ public sealed class StartupConfigurationValidator(
{
var failures = new List<string>();
// Building the options resolves Heimdall:AppSecretFile, and a bad file stops startup in every environment.
_ = heimdallOptions.Value;
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
var conceptVotes = conceptVoteOptions.Value;
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))

View file

@ -64,7 +64,8 @@ builder.Services
builder.Services
.AddOptions<HeimdallOptions>()
.BindConfiguration(HeimdallOptions.SectionName);
.BindConfiguration(HeimdallOptions.SectionName)
.PostConfigure<ILogger<HeimdallOptions>>(HeimdallOptions.ResolveAppSecret);
builder.Services
.AddOptions<StripeOptions>()

View file

@ -28,6 +28,7 @@
"BaseUrl": "https://heimdall.gamecult.org",
"AppSlug": "bifrost",
"AppSecret": "",
"AppSecretFile": "",
"DiscordGuildId": "",
"DiscordAllowedRoleIds": [],
"PatreonTierTitle": "Inner Sanctum",

View file

@ -0,0 +1,157 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class HeimdallAppSecretFileTests : IDisposable
{
private const string FileSecret = "file-secret-7f3a91c2";
private const string EnvSecret = "env-secret-0b5d44e8";
private readonly string _directory = Directory.CreateTempSubdirectory("bifrost-appsecret-").FullName;
private readonly CapturingLoggerProvider _logs = new();
public void Dispose() => Directory.Delete(_directory, recursive: true);
private string Write(string content)
{
var path = Path.Combine(_directory, Guid.NewGuid().ToString("N"));
File.WriteAllText(path, content);
return path;
}
private HeimdallOptions Resolve(string appSecret = "", string appSecretFile = "")
{
var options = new HeimdallOptions { AppSecret = appSecret, AppSecretFile = appSecretFile };
HeimdallOptions.ResolveAppSecret(options, _logs.CreateLogger("test"));
return options;
}
private void AssertNoSecretLogged()
{
foreach (var line in _logs.Lines)
{
Assert.DoesNotContain(FileSecret, line);
Assert.DoesNotContain(EnvSecret, line);
}
}
[Theory]
[InlineData("")]
[InlineData("\n")]
[InlineData("\r\n")]
public void The_file_is_the_secret_with_only_the_trailing_newline_trimmed(string ending)
{
var options = Resolve(appSecretFile: Write(FileSecret + ending));
Assert.Equal(FileSecret, options.AppSecret);
Assert.False(options.IsDiscordConfigured); // no guild configured
Assert.True(options.IsPatreonConfigured);
Assert.Empty(_logs.Lines);
}
[Fact]
public void Inner_whitespace_survives_the_trim()
{
Assert.Equal(" a b ", Resolve(appSecretFile: Write(" a b \n")).AppSecret);
}
[Fact]
public void The_file_wins_over_the_environment_value_and_the_deprecation_is_logged()
{
var options = Resolve(EnvSecret, Write(FileSecret + "\n"));
Assert.Equal(FileSecret, options.AppSecret);
Assert.Contains(_logs.Lines, line => line.Contains("deprecated"));
AssertNoSecretLogged();
}
[Fact]
public void A_missing_file_fails_closed_naming_option_and_path_but_no_secret()
{
var path = Path.Combine(_directory, "absent");
var failure = Assert.Throws<InvalidOperationException>(() => Resolve(EnvSecret, path));
Assert.Contains("Heimdall:AppSecretFile", failure.Message);
Assert.Contains(path, failure.Message);
Assert.DoesNotContain(EnvSecret, failure.Message);
}
[Fact]
public void A_directory_in_place_of_the_file_fails_closed()
{
Assert.Throws<InvalidOperationException>(() => Resolve(appSecretFile: _directory));
}
[Theory]
[InlineData("")]
[InlineData("\n")]
[InlineData(" \r\n")]
public void An_empty_file_fails_closed_even_when_the_environment_value_is_set(string content)
{
var path = Write(content);
var failure = Assert.Throws<InvalidOperationException>(() => Resolve(EnvSecret, path));
Assert.Contains("Heimdall:AppSecretFile", failure.Message);
Assert.Contains(path, failure.Message);
Assert.DoesNotContain(EnvSecret, failure.Message);
}
[Fact]
public void The_environment_value_alone_still_works_and_warns_without_logging_it()
{
var options = Resolve(EnvSecret);
Assert.Equal(EnvSecret, options.AppSecret);
Assert.Single(_logs.Lines, line => line.Contains("deprecated") && line.Contains("Heimdall:AppSecretFile"));
AssertNoSecretLogged();
}
[Fact]
public void Neither_set_is_unconfigured_as_before_and_silent()
{
var options = Resolve();
Assert.Equal(string.Empty, options.AppSecret);
Assert.False(options.IsPatreonConfigured);
Assert.Empty(_logs.Lines);
}
private TestWebApplicationFactory Host(string file) => new TestHost(file, _logs);
private sealed class TestHost(string file, CapturingLoggerProvider logs) : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Heimdall:AppSecretFile"] = file }));
builder.ConfigureLogging(logging => logging.AddProvider(logs));
}
}
[Fact]
public void The_running_host_resolves_the_file_into_its_options()
{
using var host = Host(Write(FileSecret + "\n"));
Assert.Equal(FileSecret, host.Services.GetRequiredService<IOptions<HeimdallOptions>>().Value.AppSecret);
}
[Fact]
public void The_running_host_refuses_to_start_on_a_missing_file_in_every_environment()
{
using var host = Host(Path.Combine(_directory, "absent"));
var failure = Assert.ThrowsAny<Exception>(() => host.CreateClient());
Assert.Contains("Heimdall:AppSecretFile", failure.ToString());
}
}