Startup validation: tag secret minimum length and prefix lengths inside the address family, naming keys not values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
162d7b1f0d
commit
eca71f589c
4 changed files with 69 additions and 7 deletions
|
|
@ -4,7 +4,12 @@ public sealed class ConceptVoteOptions
|
|||
{
|
||||
public const string SectionName = "ConceptVotes";
|
||||
|
||||
// Keys the network tag. Required outside Development and Testing (StartupConfigurationValidator).
|
||||
// HMAC-SHA256 output is 32 bytes (RFC 2104 advises a key at least as long as the hash output), so a shorter
|
||||
// secret makes the tag brute-forceable over the IPv4 space if the table leaks.
|
||||
public const int MinNetworkTagSecretLength = 32;
|
||||
|
||||
// Keys the network tag. Required outside Development and Testing, at least MinNetworkTagSecretLength
|
||||
// characters (StartupConfigurationValidator).
|
||||
public string NetworkTagSecret { get; init; } = string.Empty;
|
||||
|
||||
// The research note (docs/research/anonymous-vote-stuffing-prior-art.md:57-61) finds no source for a
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ namespace Bifrost.Web.Features.ConceptVotes;
|
|||
|
||||
public static class NetworkPrefix
|
||||
{
|
||||
public const int IPv4Bits = 32;
|
||||
public const int IPv6Bits = 128;
|
||||
|
||||
public static bool IsValidLength(int length, int addressBits) => length >= 0 && length <= addressBits;
|
||||
|
||||
// The only prefix computation: IPv4-mapped IPv6 folds to IPv4, then the address is masked. Returns CIDR text.
|
||||
public static string Of(IPAddress address, int ipv4Length, int ipv6Length)
|
||||
{
|
||||
|
|
@ -16,7 +21,7 @@ public static class NetworkPrefix
|
|||
|
||||
var bytes = address.GetAddressBytes();
|
||||
var length = bytes.Length == 4 ? ipv4Length : ipv6Length;
|
||||
if (length < 0 || length > bytes.Length * 8)
|
||||
if (!IsValidLength(length, bytes.Length * 8))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ipv4Length), "Prefix length is outside the address family.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.ConceptVotes;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
|
@ -10,7 +11,8 @@ public sealed class StartupConfigurationValidator(
|
|||
IOptions<GitHubAppOptions> gitHubAppOptions,
|
||||
IOptions<HeimdallOptions> heimdallOptions,
|
||||
IOptions<StripeOptions> stripeOptions,
|
||||
IOptions<BifrostHostOptions> hostOptions)
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
IOptions<ConceptVoteOptions> conceptVoteOptions)
|
||||
{
|
||||
public void Validate()
|
||||
{
|
||||
|
|
@ -71,9 +73,22 @@ public sealed class StartupConfigurationValidator(
|
|||
failures.Add("DataProtection:KeysPath must be configured for production.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuration["ConceptVotes:NetworkTagSecret"]))
|
||||
var conceptVotes = conceptVoteOptions.Value;
|
||||
if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) ||
|
||||
conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength)
|
||||
{
|
||||
failures.Add("ConceptVotes:NetworkTagSecret must be configured for production.");
|
||||
failures.Add(
|
||||
$"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production.");
|
||||
}
|
||||
|
||||
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 (failures.Count > 0)
|
||||
|
|
|
|||
|
|
@ -157,6 +157,34 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("short-secret")]
|
||||
[InlineData("0123456789012345678901234567890")]
|
||||
public void Production_refuses_a_short_network_tag_secret_without_echoing_it(string secret)
|
||||
{
|
||||
var refusal = Assert.Throws<InvalidOperationException>(
|
||||
() => Validate(keysPath: "/keys", tagSecret: secret));
|
||||
|
||||
Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message);
|
||||
Assert.DoesNotContain(secret, refusal.Message);
|
||||
Validate(keysPath: "/keys", tagSecret: "01234567890123456789012345678901");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(33, 56, "ConceptVotes:IPv4PrefixLength")]
|
||||
[InlineData(-1, 56, "ConceptVotes:IPv4PrefixLength")]
|
||||
[InlineData(32, 129, "ConceptVotes:IPv6PrefixLength")]
|
||||
[InlineData(32, -1, "ConceptVotes:IPv6PrefixLength")]
|
||||
public void Production_refuses_prefix_lengths_outside_the_address_family(int v4, int v6, string key)
|
||||
{
|
||||
var refusal = Assert.Throws<InvalidOperationException>(
|
||||
() => Validate(keysPath: "/keys", ipv4Length: v4, ipv6Length: v6));
|
||||
|
||||
Assert.Contains(key, refusal.Message);
|
||||
Validate(keysPath: "/keys", ipv4Length: 32, ipv6Length: 128);
|
||||
Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Development_does_not_require_keys_path_or_tag_secret()
|
||||
{
|
||||
|
|
@ -208,18 +236,21 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
}
|
||||
}
|
||||
|
||||
private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef";
|
||||
|
||||
private static void Validate(
|
||||
string? keysPath,
|
||||
string environment = "Production",
|
||||
string? connectionString = "Host=db",
|
||||
string? tagSecret = "tag-secret")
|
||||
string? tagSecret = ValidTagSecret,
|
||||
int ipv4Length = 32,
|
||||
int ipv6Length = 56)
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["AllowedHosts"] = "bifrost.example",
|
||||
["ConnectionStrings:Bifrost"] = connectionString,
|
||||
["DataProtection:KeysPath"] = keysPath,
|
||||
["ConceptVotes:NetworkTagSecret"] = tagSecret
|
||||
};
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
var hostEnvironment = new StubHostEnvironment(environment);
|
||||
|
|
@ -235,6 +266,12 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
{
|
||||
PublicBaseUrl = "https://bifrost.example",
|
||||
ExpectedHost = "bifrost.example"
|
||||
}),
|
||||
Options.Create(new ConceptVoteOptions
|
||||
{
|
||||
NetworkTagSecret = tagSecret ?? string.Empty,
|
||||
IPv4PrefixLength = ipv4Length,
|
||||
IPv6PrefixLength = ipv6Length
|
||||
})).Validate();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue