Refuse production startup when the Heimdall app secret is blank

A missing secret silently switched Discord and Patreon member sign-in off.
Outside Development and Testing the validator now fails naming
Heimdall:AppSecretFile and Heimdall:AppSecret, never a value.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-12 19:23:43 +00:00
commit 1739d5552a
3 changed files with 42 additions and 2 deletions

View file

@ -8,7 +8,8 @@
- File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names
`Heimdall:AppSecretFile` and the path, never the secret.
- Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value).
- Neither set: Discord and Patreon sign-in stay off, as before.
- Neither set: startup fails outside Development and Testing, naming `Heimdall:AppSecretFile` and `Heimdall:AppSecret`
(`StartupConfigurationValidator`). Development and Testing still start, with Discord and Patreon sign-in off.
Under systemd, pass the file with `LoadCredential=heimdall-app-secret:<root-owned file>` and
`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`.

View file

@ -105,6 +105,13 @@ public sealed class StartupConfigurationValidator(
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
}
// The options are already resolved, so a secret from Heimdall:AppSecretFile or Heimdall:AppSecret lands in AppSecret.
// Blank would silently switch Discord and Patreon member sign-in off.
if (string.IsNullOrWhiteSpace(heimdallOptions.Value.AppSecret))
{
failures.Add("Heimdall:AppSecretFile (or Heimdall:AppSecret) must supply the Heimdall app secret outside development.");
}
if (stripeOptions.Value.EnableCheckout)
{
if (!stripeOptions.Value.IsCheckoutConfigured)

View file

@ -145,6 +145,36 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
Assert.DoesNotContain(canary, refusal.Message);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Production_without_a_heimdall_app_secret_refuses_startup_naming_the_options(string blank)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", appSecret: blank));
Assert.Contains("Heimdall:AppSecretFile", refusal.Message);
Assert.Contains("Heimdall:AppSecret", refusal.Message);
}
[Fact]
public void Production_with_a_heimdall_app_secret_passes_and_the_refusal_never_echoes_it()
{
Validate(keysPath: "/keys", appSecret: ValidAppSecret);
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: null, appSecret: ValidAppSecret));
Assert.DoesNotContain(ValidAppSecret, refusal.Message);
}
[Theory]
[InlineData("Development")]
[InlineData("Testing")]
public void Development_and_Testing_start_without_a_heimdall_app_secret(string environment)
{
Validate(keysPath: null, environment: environment, appSecret: "");
}
[Fact]
public void Production_without_network_tag_secret_fails_and_never_echoes_values()
{
@ -315,12 +345,14 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
}
private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef";
private const string ValidAppSecret = "app-secret-5c0e91d4";
private static void Validate(
string? keysPath,
string environment = "Production",
string? connectionString = "Host=db",
string? tagSecret = ValidTagSecret,
string appSecret = ValidAppSecret,
int ipv4Length = 32,
int ipv6Length = 56,
string[]? allowedOrigins = null,
@ -344,7 +376,7 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
hostEnvironment,
Options.Create(new GitHubOAuthOptions { ClientId = "id", ClientSecret = "secret" }),
Options.Create(new GitHubAppOptions { EnableWebhookSync = false }),
Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false }),
Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false, AppSecret = appSecret }),
Options.Create(new StripeOptions()),
Options.Create(new BifrostHostOptions
{