Bifrost/tests/Bifrost.Web.Tests/HostTrustTests.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

375 lines
14 KiB
C#

using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public HostTrustTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData("::ffff:127.0.0.1")]
public async Task Forwarded_for_from_loopback_peer_becomes_remote_ip(string peer)
{
var context = await RunForwardedHeaders(peer, "203.0.113.7", "https");
Assert.Equal(IPAddress.Parse("203.0.113.7"), context.Connection.RemoteIpAddress);
Assert.Equal("https", context.Request.Scheme);
}
[Fact]
public async Task Forwarded_for_from_non_loopback_peer_is_ignored()
{
var context = await RunForwardedHeaders("198.51.100.9", "203.0.113.7", "https");
Assert.Equal(IPAddress.Parse("198.51.100.9"), context.Connection.RemoteIpAddress);
Assert.Equal("http", context.Request.Scheme);
}
[Fact]
public async Task Only_the_rightmost_forwarded_entry_is_used()
{
var context = await RunForwardedHeaders("127.0.0.1", "6.6.6.6, 203.0.113.7", null);
Assert.Equal(IPAddress.Parse("203.0.113.7"), context.Connection.RemoteIpAddress);
}
[Fact]
public async Task A_loopback_forwarded_entry_does_not_chain_to_earlier_entries()
{
var context = await RunForwardedHeaders("127.0.0.1", "6.6.6.6, 127.0.0.1", null);
Assert.Equal(IPAddress.Loopback, context.Connection.RemoteIpAddress);
}
[Fact]
public async Task Only_the_exact_loopback_addresses_are_trusted_not_the_loopback_range()
{
var context = await RunForwardedHeaders("127.0.0.2", "203.0.113.7", null);
Assert.Equal(IPAddress.Parse("127.0.0.2"), context.Connection.RemoteIpAddress);
}
[Fact]
public void Payload_protected_under_one_content_root_unprotects_under_another()
{
var root = NewRoot();
var keys = Path.Combine(root, "keys");
try
{
using var hostA = WithContentRoot(MakeRelease(root, "release-a"), keys);
using var hostB = WithContentRoot(MakeRelease(root, "release-b"), keys);
var sealedPayload = Protect(hostA, "payload");
Assert.Equal("payload", Unprotect(hostB, sealedPayload));
Assert.NotEmpty(Directory.GetFiles(keys, "key-*.xml"));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void A_different_keys_path_does_not_unprotect_the_payload()
{
var root = NewRoot();
try
{
using var hostA = WithContentRoot(MakeRelease(root, "release-a"), Path.Combine(root, "keys-a"));
using var hostB = WithContentRoot(MakeRelease(root, "release-b"), Path.Combine(root, "keys-b"));
var sealedPayload = Protect(hostA, "payload");
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
() => Unprotect(hostB, sealedPayload));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void The_container_image_does_not_set_the_forwarded_headers_switch()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Dockerfile")))
{
directory = directory.Parent;
}
Assert.NotNull(directory);
Assert.DoesNotContain(
"ASPNETCORE_FORWARDEDHEADERS_ENABLED",
File.ReadAllText(Path.Combine(directory!.FullName, "Dockerfile")));
}
[Fact]
public void Production_without_keys_path_fails_startup_validation()
{
var withoutKey = Assert.Throws<InvalidOperationException>(() => Validate(keysPath: null));
Assert.Contains("DataProtection:KeysPath", withoutKey.Message);
Validate(keysPath: "/not/echoed/anywhere");
}
[Fact]
public void Startup_refusal_names_the_key_and_never_echoes_configured_values()
{
var canary = "canary-secret-4f1e";
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: null, connectionString: canary));
Assert.Contains("DataProtection:KeysPath", refusal.Message);
Assert.DoesNotContain(canary, refusal.Message);
}
[Fact]
public void Production_without_network_tag_secret_fails_and_never_echoes_values()
{
var canary = "canary-keys-path-77aa";
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: canary, tagSecret: " "));
Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message);
Assert.DoesNotContain(canary, refusal.Message);
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);
}
[Theory]
[InlineData("Development")]
[InlineData("Testing")]
[InlineData("Production")]
public void Every_environment_refuses_prefix_lengths_outside_the_address_family(string environment)
{
var v4 = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", environment: environment, ipv4Length: 33));
Assert.Contains("ConceptVotes:IPv4PrefixLength", v4.Message);
Assert.DoesNotContain("IPv6PrefixLength", v4.Message);
var v6 = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", environment: environment, ipv6Length: 129));
Assert.Contains("ConceptVotes:IPv6PrefixLength", v6.Message);
Assert.DoesNotContain("IPv4PrefixLength", v6.Message);
Validate(keysPath: "/keys", environment: environment, ipv4Length: 0, ipv6Length: 0);
}
[Theory]
[InlineData("Development", 0, 1, 5, 1, "ConceptVotes:CastPermits")]
[InlineData("Testing", 30, 0, 5, 1, "ConceptVotes:CastPermits")]
[InlineData("Production", 30, 1, 0, 1, "ConceptVotes:MintPermits")]
[InlineData("Production", 30, 1, 5, 0, "ConceptVotes:MintPermits")]
public void Every_environment_refuses_a_vote_budget_that_cannot_be_enforced(
string environment,
int castPermits,
int castMinutes,
int mintPermits,
int mintMinutes,
string key)
{
var refusal = Assert.Throws<InvalidOperationException>(() => Validate(
keysPath: "/keys",
environment: environment,
castPermits: castPermits,
castWindow: TimeSpan.FromMinutes(castMinutes),
mintPermits: mintPermits,
mintWindow: TimeSpan.FromMinutes(mintMinutes)));
Assert.Contains(key, refusal.Message);
Validate(keysPath: "/keys", environment: environment);
}
[Theory]
[InlineData("Development")]
[InlineData("Testing")]
[InlineData("Production")]
public void Every_environment_refuses_a_vote_table_cap_below_one(string environment)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", environment: environment, maxTrackedKeys: 0));
Assert.Contains("ConceptVotes:MaxTrackedKeys", refusal.Message);
}
[Theory]
[InlineData("*")]
[InlineData("")]
[InlineData("null")]
[InlineData("https://canary-origin-5d1c.example/")]
[InlineData("https://canary-origin-5d1c.example/path")]
[InlineData("https://Canary-Origin-5d1c.example")]
[InlineData("http://canary-origin-5d1c.example")]
[InlineData("canary-origin-5d1c.example")]
[InlineData("https://canary-origin-5d1c.example:443")]
[InlineData("https://user@canary-origin-5d1c.example")]
public void Production_refuses_an_allowed_origin_that_is_not_an_exact_https_origin_without_echoing_it(string origin)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", origin]));
Assert.Contains("ConceptVotes:AllowedOrigins", refusal.Message);
Assert.DoesNotContain("canary-origin-5d1c", refusal.Message, StringComparison.OrdinalIgnoreCase);
Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", "https://other.example:8443"]);
Validate(keysPath: "/keys", allowedOrigins: []);
}
[Fact]
public void Development_does_not_require_keys_path_or_tag_secret()
{
Validate(keysPath: null, environment: Environments.Development, tagSecret: null);
}
private async Task<HttpContext> RunForwardedHeaders(string peer, string forwardedFor, string? forwardedProto)
{
var options = _factory.Services.GetRequiredService<IOptions<ForwardedHeadersOptions>>();
var middleware = new ForwardedHeadersMiddleware(
_ => Task.CompletedTask,
NullLoggerFactory.Instance,
options);
var context = new DefaultHttpContext();
context.Request.Scheme = "http";
context.Connection.RemoteIpAddress = IPAddress.Parse(peer);
context.Request.Headers["X-Forwarded-For"] = forwardedFor;
if (forwardedProto is not null)
{
context.Request.Headers["X-Forwarded-Proto"] = forwardedProto;
}
await middleware.Invoke(context);
return context;
}
private static string NewRoot() =>
Path.Combine(Path.GetTempPath(), "bifrost-dp-" + Guid.NewGuid().ToString("N"));
private static string MakeRelease(string root, string name) =>
Directory.CreateDirectory(Path.Combine(root, name)).FullName;
private static string Protect(TestWebApplicationFactory host, string value) =>
host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("P").Protect(value);
private static string Unprotect(TestWebApplicationFactory host, string value) =>
host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("P").Unprotect(value);
private TestWebApplicationFactory WithContentRoot(string contentRoot, string keysPath) =>
new KeyRingFactory(contentRoot, keysPath);
private sealed class KeyRingFactory(string contentRoot, string keysPath) : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseContentRoot(contentRoot);
builder.UseSetting("DataProtection:KeysPath", keysPath);
}
}
private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef";
private static void Validate(
string? keysPath,
string environment = "Production",
string? connectionString = "Host=db",
string? tagSecret = ValidTagSecret,
int ipv4Length = 32,
int ipv6Length = 56,
string[]? allowedOrigins = null,
int castPermits = 30,
TimeSpan? castWindow = null,
int mintPermits = 5,
TimeSpan? mintWindow = null,
int maxTrackedKeys = 100_000)
{
var values = new Dictionary<string, string?>
{
["AllowedHosts"] = "bifrost.example",
["ConnectionStrings:Bifrost"] = connectionString,
["DataProtection:KeysPath"] = keysPath,
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
var hostEnvironment = new StubHostEnvironment(environment);
new StartupConfigurationValidator(
configuration,
hostEnvironment,
Options.Create(new GitHubOAuthOptions { ClientId = "id", ClientSecret = "secret" }),
Options.Create(new GitHubAppOptions { EnableWebhookSync = false }),
Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false }),
Options.Create(new StripeOptions()),
Options.Create(new BifrostHostOptions
{
PublicBaseUrl = "https://bifrost.example",
ExpectedHost = "bifrost.example"
}),
Options.Create(new ConceptVoteOptions
{
NetworkTagSecret = tagSecret ?? string.Empty,
IPv4PrefixLength = ipv4Length,
IPv6PrefixLength = ipv6Length,
AllowedOrigins = allowedOrigins ?? [],
CastPermits = castPermits,
CastWindow = castWindow ?? TimeSpan.FromMinutes(10),
MintPermits = mintPermits,
MintWindow = mintWindow ?? TimeSpan.FromHours(1),
MaxTrackedKeys = maxTrackedKeys
})).Validate();
}
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment
{
public string EnvironmentName { get; set; } = environmentName;
public string ApplicationName { get; set; } = "Bifrost.Web";
public string ContentRootPath { get; set; } = string.Empty;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}