Heimdall__AppSecretFile: keep the Heimdall app secret out of the process environment #78
9 changed files with 245 additions and 5 deletions
|
|
@ -41,7 +41,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only
|
|||
|
||||
### Data Protection on the live host
|
||||
|
||||
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
|
||||
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*` (the app secret as `Heimdall__AppSecret`; `Heimdall__AppSecretFile` is the replacement), `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
|
||||
19. The service's working directory resolves to the release directory: `readlink /proc/<MainPID>/cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty).
|
||||
|
||||
### Proxy and client address
|
||||
|
|
|
|||
|
|
@ -54,7 +54,13 @@ permits, windows and `MaxTrackedKeys`.
|
|||
|
||||
New since the live release, and not read by the validator:
|
||||
|
||||
- **`Heimdall__AppSecret`**: required for Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and
|
||||
- **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one
|
||||
trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never
|
||||
rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file
|
||||
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`
|
||||
(`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted
|
||||
|
|
|
|||
17
docs/heimdall-app-secret-file.md
Normal file
17
docs/heimdall-app-secret-file.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Heimdall app secret from a file
|
||||
|
||||
`Heimdall:AppSecret` (`Heimdall__AppSecret`) is Bifrost's app secret for Heimdall slug `bifrost`; it is sent as the
|
||||
`x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile`
|
||||
(`Heimdall__AppSecretFile`) names a file holding it instead.
|
||||
|
||||
- File set: Bifrost reads it once at startup and trailing newlines are trimmed. The file wins over `Heimdall__AppSecret`.
|
||||
- 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.
|
||||
|
||||
Under systemd, pass the file with `LoadCredential=heimdall-app-secret:<root-owned file>` and
|
||||
`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`.
|
||||
|
||||
Code: `HeimdallOptions.ResolveAppSecret` (`src/Bifrost.Web/Configuration/HeimdallOptions.cs`), wired in `Program.cs`, forced at
|
||||
startup by `StartupConfigurationValidator.Validate`. Tests: `tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs`.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"BaseUrl": "https://heimdall.gamecult.org",
|
||||
"AppSlug": "bifrost",
|
||||
"AppSecret": "",
|
||||
"AppSecretFile": "",
|
||||
"DiscordGuildId": "",
|
||||
"DiscordAllowedRoleIds": [],
|
||||
"PatreonTierTitle": "Inner Sanctum",
|
||||
|
|
|
|||
157
tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs
Normal file
157
tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs
Normal 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_trailing_newlines_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.WarningLines, 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.WarningLines, 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,19 @@ namespace Bifrost.Web.Tests.Support;
|
|||
public sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
private readonly List<string> _warnings = [];
|
||||
|
||||
/// <summary>Lines logged at Warning or above, to pin that an operator-facing notice reaches the journal.</summary>
|
||||
public IReadOnlyList<string> WarningLines
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lines)
|
||||
{
|
||||
return _warnings.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Lines
|
||||
{
|
||||
|
|
@ -38,7 +51,12 @@ public sealed class CapturingLoggerProvider : ILoggerProvider
|
|||
{
|
||||
lock (owner._lines)
|
||||
{
|
||||
owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception));
|
||||
var line = formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception);
|
||||
owner._lines.Add(line);
|
||||
if (logLevel >= LogLevel.Warning)
|
||||
{
|
||||
owner._warnings.Add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue