Bifrost/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs
Meta Vens a8c3ecf495 Vote budgets: monotonic windows, a key cap, 30 mints an hour and a readable 429
FixedWindowBudget times windows on the clock's monotonic timestamps so a wall-clock
step cannot stretch or reset one, expires windows from a start-ordered queue instead
of a periodic sweep, and holds at most MaxTrackedKeys keys: a new key at the cap is
denied with the true wait and a held key is never evicted. MintPermits is 30 per hour
per the operator ruling, and a 429 now carries a plain-text reason; a refused new
device is told it can sign in. Not yet built or tested at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 17:32:01 +02:00

135 lines
5.3 KiB
C#

using Bifrost.Web.Configuration;
using Bifrost.Web.Features.ConceptVotes;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared;
public sealed class StartupConfigurationValidator(
IConfiguration configuration,
IHostEnvironment hostEnvironment,
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
IOptions<GitHubAppOptions> gitHubAppOptions,
IOptions<HeimdallOptions> heimdallOptions,
IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions)
{
public void Validate()
{
var failures = new List<string>();
// 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))
{
failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}.");
}
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits))
{
failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}.");
}
if (conceptVotes.CastPermits < 1 || conceptVotes.CastWindow <= TimeSpan.Zero)
{
failures.Add("ConceptVotes:CastPermits must be at least 1 and ConceptVotes:CastWindow must be positive.");
}
if (conceptVotes.MintPermits < 1 || conceptVotes.MintWindow <= TimeSpan.Zero)
{
failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive.");
}
if (conceptVotes.MaxTrackedKeys < 1)
{
failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1.");
}
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
{
Refuse(failures);
return;
}
if (!gitHubOAuthOptions.Value.IsConfigured)
{
failures.Add("GitHub OAuth client configuration is required outside development.");
}
if (gitHubAppOptions.Value.EnableWebhookSync && !gitHubAppOptions.Value.IsConfigured)
{
failures.Add("GitHub App configuration is required when webhook sync is enabled.");
}
if (!hostOptions.Value.IsConfigured)
{
failures.Add("Host:PublicBaseUrl and Host:ExpectedHost must be configured.");
}
if (heimdallOptions.Value.EnablePatronSupportIntake &&
!heimdallOptions.Value.IsPatronSupportIntakeConfigured)
{
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
}
if (stripeOptions.Value.EnableCheckout)
{
if (!stripeOptions.Value.IsCheckoutConfigured)
{
failures.Add("Stripe:SecretKey, Stripe:SuccessUrl, and Stripe:CancelUrl are required when Stripe checkout is enabled.");
}
// Stripe:GeneralPatronageGitHubLogin is only a legacy fallback for old sessions
// that predate account-bound checkout metadata.
}
var allowedHosts = configuration["AllowedHosts"] ?? string.Empty;
if (hostOptions.Value.RequireStrictHostValidation &&
(string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts.Contains('*')))
{
failures.Add("AllowedHosts must be explicitly configured for production.");
}
var connectionString = configuration.GetConnectionString("Bifrost");
if (string.IsNullOrWhiteSpace(connectionString))
{
failures.Add("Connection string 'Bifrost' must be configured.");
}
if (string.IsNullOrWhiteSpace(configuration["DataProtection:KeysPath"]))
{
failures.Add("DataProtection:KeysPath must be configured for production.");
}
if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) ||
conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength)
{
failures.Add(
$"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production.");
}
if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin))
{
failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash.");
}
Refuse(failures);
}
private static void Refuse(List<string> failures)
{
if (failures.Count > 0)
{
throw new InvalidOperationException(
"Bifrost startup configuration is incomplete: " + string.Join(" ", failures));
}
}
// The authority form is the canonical spelling, so a wildcard, path, query, upper-case host or default port
// differs from it and is refused; user info is part of the authority, so it is refused on its own.
private static bool IsExactHttpsOrigin(string? origin) =>
Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
uri.Scheme == Uri.UriSchemeHttps &&
uri.UserInfo.Length == 0 &&
string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal);
}