From 1739d5552a9745dedffc2ff703b31a59151fd822 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 19:23:43 +0000 Subject: [PATCH] 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 --- docs/heimdall-app-secret-file.md | 3 +- .../Shared/StartupConfigurationValidator.cs | 7 ++++ tests/Bifrost.Web.Tests/HostTrustTests.cs | 34 ++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/heimdall-app-secret-file.md b/docs/heimdall-app-secret-file.md index 2f8c283..bee017c 100644 --- a/docs/heimdall-app-secret-file.md +++ b/docs/heimdall-app-secret-file.md @@ -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:` and `Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`. diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 2ab16cf..58fe2fb 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -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) diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 717062d..7abadb8 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -145,6 +145,36 @@ public sealed class HostTrustTests : IClassFixture 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( + () => 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( + () => 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 } 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 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 {