From 1739d5552a9745dedffc2ff703b31a59151fd822 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 19:23:43 +0000 Subject: [PATCH 1/2] 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 { From 1771f157895b3f3457eef197180629bd9e1c32b1 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 19:27:57 +0000 Subject: [PATCH 2/2] Soul fixes: deploy doc says production refuses a blank secret; name the echo test for what it checks Co-Authored-By: Claude Sonnet 5.5 --- docs/deploy-main-to-live.md | 9 +++++---- tests/Bifrost.Web.Tests/HostTrustTests.cs | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/deploy-main-to-live.md b/docs/deploy-main-to-live.md index 9e50e98..2e635cb 100644 --- a/docs/deploy-main-to-live.md +++ b/docs/deploy-main-to-live.md @@ -60,9 +60,10 @@ New since the live release, and not read by the validator: stops startup in every environment, with a message that names the option and path and never the value. Ops steps: `docs/heimdall-app-secret-file.md`. - **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for - Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and - `IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail, - so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost` + Discord and Patreon sign-in. Outside Development and Testing startup fails when neither this nor + `Heimdall__AppSecretFile` supplies a non-blank secret (the message names both options, never a value), because + `HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` would otherwise be false and those sign-ins silently + stop working (GitHub sign-in is unaffected). The value is Heimdall's app secret for slug `bifrost` (`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted from logs. - **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be @@ -135,7 +136,7 @@ All must hold before step 1. A missing one means the window is not used. not a workstation. 6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret` included and at least 32 characters (a count, not the value), and the `Heimdall__AppSecret` line is in `bifrost.env` - (mode 600, owner `bifrost`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved. + (mode 600, owner `bifrost`; a build with the empty-secret refusal does not start without it or `Heimdall__AppSecretFile`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved. 7. **Quiet.** No motion closing and no known vote in flight inside the window. Check the motions page. Members told that everyone is signed out once (see "What moves"). 8. **Watchdog.** `bifrost-watchdog-notice.timer` fires one minute after boot and one minute after each run diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 7abadb8..8ad0a0a 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -158,7 +158,7 @@ public sealed class HostTrustTests : IClassFixture } [Fact] - public void Production_with_a_heimdall_app_secret_passes_and_the_refusal_never_echoes_it() + public void Production_with_a_heimdall_app_secret_passes_and_other_refusals_never_echo_it() { Validate(keysPath: "/keys", appSecret: ValidAppSecret);