From fbf6e43955dbd1efaa1fe0b7c162a1ab6c68e53f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:00:23 +0200 Subject: [PATCH] Concept vote rate limits: cast and mint budgets per account or network prefix One FixedWindowBudget (injectable clock) owned by ConceptVoteRateLimits; the vote page 429s with Retry-After before any row or cookie. Prefix lengths and budgets are validated in every environment. Pins the empty Origin branch of CastRequestGuard. Unverified; not yet built. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 14 + .../ConceptVotes/ConceptVoteRateLimits.cs | 28 ++ .../Features/Shared/FixedWindowBudget.cs | 78 ++++ .../Shared/StartupConfigurationValidator.cs | 45 +- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 44 +- src/Bifrost.Web/Program.cs | 1 + .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 58 +-- .../ConceptVoteRateLimitTests.cs | 392 ++++++++++++++++++ .../FixedWindowBudgetTests.cs | 93 +++++ tests/Bifrost.Web.Tests/HostTrustTests.cs | 56 ++- .../Support/CapturingLoggerProvider.cs | 45 ++ .../Support/TestAuthHandler.cs | 9 +- .../Support/TestWebApplicationFactory.cs | 2 + 13 files changed, 795 insertions(+), 70 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs create mode 100644 src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs create mode 100644 tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index a5e0781..41a866e 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -20,6 +20,20 @@ public sealed class ConceptVoteOptions // default weighed in the note, not a standard. Tune on observed abuse. public int IPv6PrefixLength { get; init; } = 56; + // Vote budgets (Features/ConceptVotes/ConceptVoteRateLimits). The research note + // (docs/research/anonymous-vote-stuffing-prior-art.md section 3) gives no numbers, so these are first values to tune on + // observed abuse. Cast: one visitor reading through the Mill can vote on a few dozen concepts in a sitting, so 30 per + // 10 minutes per account or network prefix does not trouble a person and caps a script at 180 an hour. + public int CastPermits { get; init; } = 30; + + public TimeSpan CastWindow { get; init; } = TimeSpan.FromMinutes(10); + + // Mint: a new device cookie is a new voter identity, the cheapest stuffing lever, and a household has few devices, + // so 5 new devices per hour per network prefix. + public int MintPermits { get; init; } = 5; + + public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1); + // The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly // https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator // refuses anything else outside Development and Testing, because "*" would allow every origin. diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs new file mode 100644 index 0000000..aac9802 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs @@ -0,0 +1,28 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Shared; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The one owner of the vote budgets. Cast is spent by every cast and retract; mint is spent additionally by a cast that +// is about to mint a device cookie. A denial returns how long to wait and writes nothing. The partition is the account +// when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the forwarded-headers +// middleware left on the connection. No header and no client input ever becomes a key. +public sealed class ConceptVoteRateLimits(IOptions options, TimeProvider clock) +{ + private readonly ConceptVoteOptions opts = options.Value; + private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, clock); + private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, clock); + + public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) => + cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait) + ? null + : wait; + + public TimeSpan? DenyMint(IPAddress? client) => + mint.TryAcquire(NetKey(client), out var wait) ? null : wait; + + private string NetKey(IPAddress? client) => + "net:" + (client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength)); +} diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs new file mode 100644 index 0000000..abcaa0f --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -0,0 +1,78 @@ +namespace Bifrost.Web.Features.Shared; + +// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, read against an injected +// clock. Keys are opaque and are never stored beyond their window: an entry whose window has ended is dropped by the +// next sweep, so the table is bounded by the distinct keys seen in one window. System.Threading.RateLimiting is not +// used because its limiters time themselves on Stopwatch and timers, which a test cannot move. +public sealed class FixedWindowBudget +{ + private readonly int permits; + private readonly TimeSpan window; + private readonly TimeProvider clock; + private readonly Dictionary entries = new(StringComparer.Ordinal); + private readonly object gate = new(); + private DateTimeOffset nextSweep = DateTimeOffset.MinValue; + + public FixedWindowBudget(int permits, TimeSpan window, TimeProvider clock) + { + ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero); + this.permits = permits; + this.window = window; + this.clock = clock; + } + + // How many keys are held now: bounded by the distinct keys seen in one window, not by every key ever seen. + public int TrackedKeys + { + get + { + lock (gate) + { + return entries.Count; + } + } + } + + // Takes one permit for the key. When none is left, returns false and how long until its window ends. + public bool TryAcquire(string key, out TimeSpan retryAfter) + { + var now = clock.GetUtcNow(); + lock (gate) + { + if (now >= nextSweep) + { + foreach (var expired in entries.Where(x => x.Value.Start + window <= now).Select(x => x.Key).ToList()) + { + entries.Remove(expired); + } + + nextSweep = now + window; + } + + if (!entries.TryGetValue(key, out var entry) || entry.Start + window <= now) + { + entries[key] = new Entry(now); + retryAfter = TimeSpan.Zero; + return true; + } + + if (entry.Count < permits) + { + entry.Count++; + retryAfter = TimeSpan.Zero; + return true; + } + + retryAfter = entry.Start + window - now; + return false; + } + } + + private sealed class Entry(DateTimeOffset start) + { + public DateTimeOffset Start { get; } = start; + + public int Count { get; set; } = 1; + } +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index c511413..676e628 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -16,12 +16,35 @@ public sealed class StartupConfigurationValidator( { public void Validate() { - if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + var failures = new List(); + + // 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)) { - return; + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); } - var failures = new List(); + 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 (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + { + Refuse(failures); + return; + } if (!gitHubOAuthOptions.Value.IsConfigured) { @@ -73,7 +96,6 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } - var conceptVotes = conceptVoteOptions.Value; if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) { @@ -81,21 +103,16 @@ public sealed class StartupConfigurationValidator( $"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 (!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 failures) + { if (failures.Count > 0) { throw new InvalidOperationException( diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 525811d..5e46e1d 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -17,6 +17,7 @@ public sealed class VoteModel( ICurrentBifrostActorAccessor actorAccessor, ConceptAppraisalService appraisals, VoterKeyResolver deviceCookie, + ConceptVoteRateLimits limits, IOptions options, IOptions host) : PageModel { @@ -61,7 +62,7 @@ public sealed class VoteModel( public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); - var voter = ResolveVoter(actor, mint: false); + var voter = ResolveVoter(actor); SignedIn = voter?.UserAccountId is not null; return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound(); } @@ -75,12 +76,17 @@ public sealed class VoteModel( public async Task OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor); + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter)) is { } throttled) + { + return throttled; + } + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } - var voter = ResolveVoter(actor, mint: false); await appraisals.RetractAsync(collection, key, voter, cancellationToken); return RedirectToPage(new { collection, key }); } @@ -92,12 +98,28 @@ public sealed class VoteModel( CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); + var client = HttpContext.Connection.RemoteIpAddress; + var voter = ResolveVoter(actor); + if (Throttled(limits.DenyCast(client, voter)) is { } throttled) + { + return throttled; + } + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } - var voter = ResolveVoter(actor, mint: true); + if (voter is null) + { + if (Throttled(limits.DenyMint(client)) is { } mintThrottled) + { + return mintThrottled; + } + + voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + } + var result = await appraisals.CastAsync( collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken); return result is CastResult.UnknownSubject or CastResult.RetiredSubject @@ -106,16 +128,20 @@ public sealed class VoteModel( } // Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that - // unprotects to a well-formed id; a cast without one mints it, and no other path does. - private VoterIdentity? ResolveVoter(CurrentBifrostActor actor, bool mint) + // unprotects to a well-formed id; a cast without one mints it (CastAsync, after the mint budget), and no other path does. + private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) => + VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); + + // A denied budget is a 429 that says when to retry; it writes no row and sets no cookie. + private IActionResult? Throttled(TimeSpan? retryAfter) { - var voter = VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); - if (voter is null && mint) + if (retryAfter is not { } wait) { - voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + return null; } - return voter; + Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + return new StatusCodeResult(StatusCodes.Status429TooManyRequests); } private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 94276e1..99ecd84 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -119,6 +119,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 1870b6f..b31c1c0 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -183,6 +183,22 @@ public sealed partial class ConceptVotePageTests : IClassFixture _lines = []; - - public IReadOnlyList Lines - { - get - { - lock (_lines) - { - return _lines.ToArray(); - } - } - } - - public ILogger CreateLogger(string categoryName) => new Capture(this); - - public void Dispose() - { - } - - private sealed class Capture(CapturingLoggerProvider owner) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - lock (owner._lines) - { - owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); - } - } - } - } } diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs new file mode 100644 index 0000000..7165973 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -0,0 +1,392 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +// The vote budgets through the full pipeline: a loopback peer (the proxy) forwards the client address in +// X-Forwarded-For, the clock is the test's own, and Cast is 3 per 10 minutes, Mint 2 per hour. +public sealed class ConceptVoteRateLimitTests : IClassFixture +{ + private const string Site = "https://localhost"; + private const string CookieName = "bifrost_voter"; + + private readonly LimitedFactory _factory; + + public ConceptVoteRateLimitTests(LimitedFactory factory) => _factory = factory; + + [Fact] + public async Task Burst_from_one_prefix_gets_429_while_another_prefix_is_unaffected() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.10"); + foreach (var handler in new[] { "Up", "Down", "Up" }) + { + using var ok = await device.Post(collection, handler); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var other = new Client(_factory, "203.0.113.11"); + using var unaffected = await other.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task Retract_spends_the_cast_budget() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.12"); + await device.Post(collection, "Up"); + await device.Post(collection, "Retract"); + await device.Post(collection, "Retract"); + + using var denied = await device.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Addresses_in_one_ipv6_prefix_share_a_bucket_and_another_prefix_does_not() + { + var collection = await NewSubject(); + var first = new Client(_factory, "2001:db8:1:100::1"); + var sameBlock = new Client(_factory, "2001:db8:1:1ff::2"); + await first.Post(collection, "Up"); + await first.Post(collection, "Down"); + using var third = await sameBlock.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await sameBlock.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var elsewhere = new Client(_factory, "2001:db8:1:200::1"); + using var unaffected = await elsewhere.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task The_window_ends_exactly_at_its_length_and_retry_after_counts_down_to_it() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.13"); + for (var i = 0; i < 3; i++) + { + await device.Post(collection, "Up"); + } + + using (var first = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, first.StatusCode); + Assert.Equal("600", RetryAfter(first)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(100)); + using (var later = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode); + Assert.Equal("500", RetryAfter(later)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(499)); + using (var last = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode); + Assert.Equal("1", RetryAfter(last)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(1)); + using var reopened = await device.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Forged_forwarded_header_from_non_loopback_peer_does_not_move_the_bucket() + { + var collection = await NewSubject(); + var device = new Client(_factory, "198.51.100.9") { Peer = "198.51.100.9" }; + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", forwardedFor: $"192.0.2.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", forwardedFor: "192.0.2.200"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Signed_in_accounts_are_limited_per_account() + { + var collection = await NewSubject(); + var alice = new Client(_factory, "203.0.113.20") { Account = ("7001", "alice-limits") }; + var aliceElsewhere = new Client(_factory, "203.0.113.21") { Account = ("7001", "alice-limits") }; + var bob = new Client(_factory, "203.0.113.20") { Account = ("7002", "bob-limits") }; + var visitor = new Client(_factory, "203.0.113.20"); + + await alice.Post(collection, "Up"); + await alice.Post(collection, "Down"); + await aliceElsewhere.Post(collection, "Up"); + using var denied = await aliceElsewhere.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var aliceAgain = new Client(_factory, "203.0.113.22") { Account = ("7001", "alice-limits") }; + using var sameAccountNewPrefix = await aliceAgain.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, sameAccountNewPrefix.StatusCode); + + using var bobOk = await bob.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, bobOk.StatusCode); + using var visitorOk = await visitor.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, visitorOk.StatusCode); + } + + [Fact] + public async Task Mint_budget_is_spent_only_by_minting_casts() + { + var collection = await NewSubject(); + const string prefix = "203.0.113.30"; + var first = new Client(_factory, prefix); + using (var mint = await first.Post(collection, "Up")) + { + Assert.NotNull(SetCookie(mint)); + } + + // Recasts by the existing device spend cast permits only; they would exhaust a budget of 2 if they spent mint. + using (var recast = await first.Post(collection, "Down")) + { + Assert.Equal(HttpStatusCode.Redirect, recast.StatusCode); + Assert.Null(SetCookie(recast)); + } + + using (var recastAgain = await first.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, recastAgain.StatusCode); + } + + _factory.Clock.Advance(TimeSpan.FromMinutes(10)); + var second = new Client(_factory, prefix); + using (var secondMint = await second.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, secondMint.StatusCode); + Assert.NotNull(SetCookie(secondMint)); + } + + var rowsBefore = (await Rows(collection)).Count; + var third = new Client(_factory, prefix); + using var denied = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Null(SetCookie(denied)); + Assert.Equal(rowsBefore, (await Rows(collection)).Count); + + // An hour after the first mint the budget is whole again. + _factory.Clock.Advance(TimeSpan.FromMinutes(50)); + using var reopened = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Anonymous_cast_under_the_limit_succeeds() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.40"); + + using var response = await visitor.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var row = Assert.Single(await Rows(collection)); + Assert.Equal(VoterClass.Anonymous, row.VoterClass); + Assert.Equal(ConceptStance.Up, row.Stance); + } + + [Fact] + public async Task Rejection_writes_nothing_says_when_to_retry_and_echoes_no_address() + { + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = await NewSubject(); + var visitor = new Client(factory, "203.0.113.77"); + for (var i = 0; i < 3; i++) + { + await visitor.Post(collection, i == 1 ? "Down" : "Up"); + } + + var before = Assert.Single(await Rows(collection)); + using var denied = await visitor.Post(collection, "Down"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.NotNull(RetryAfter(denied)); + Assert.Null(SetCookie(denied)); + var after = Assert.Single(await Rows(collection)); + Assert.Equal(before.Stance, after.Stance); + var seen = await denied.Content.ReadAsStringAsync() + + string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("203.0.113", seen); + Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal)); + } + + [Fact] + public async Task Reads_are_not_under_the_vote_budgets() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.50"); + for (var i = 0; i < 4; i++) + { + await visitor.Post(collection, "Up"); + } + + using var page = await visitor.Get($"/mill/{collection}/alpha"); + using var export = await visitor.Get($"/public/concepts/{collection}/tallies"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Equal(HttpStatusCode.OK, export.StatusCode); + } + + private static string? RetryAfter(HttpResponseMessage response) => + response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null; + + private static string? SetCookie(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal)) + : null; + + private async Task NewSubject() + { + var collection = "c" + Guid.NewGuid().ToString("N"); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = "alpha", + Title = "alpha", + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return collection; + } + + private async Task> Rows(string collection) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection) + .ToListAsync(); + } + + // One browser: a client address that reaches the app through the proxy, a cookie jar, optionally an account. + private sealed class Client(WebApplicationFactory factory, string address) + { + private readonly HttpClient _http = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri(Site) + }); + + private string? _cookie; + + public string Peer { get; init; } = "127.0.0.1"; + + public (string Id, string Login)? Account { get; init; } + + public Task Post(string collection, string handler, string? forwardedFor = null) => + Send( + new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler={handler}") + { + Content = new FormUrlEncodedContent([]) + }, + forwardedFor); + + public Task Get(string path) => Send(new HttpRequestMessage(HttpMethod.Get, path), null); + + private async Task Send(HttpRequestMessage request, string? forwardedFor) + { + request.Headers.TryAddWithoutValidation("Origin", Site); + request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor ?? address); + request.Headers.TryAddWithoutValidation("X-Test-Peer", Peer); + if (Account is { } account) + { + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Id", account.Id); + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Login", account.Login); + } + else + { + request.Headers.TryAddWithoutValidation("X-Test-Anonymous", "true"); + } + + if (_cookie is not null) + { + request.Headers.TryAddWithoutValidation("Cookie", _cookie); + } + + var response = await _http.SendAsync(request); + if (SetCookie(response) is { } set) + { + _cookie = set.Split(';')[0]; + } + + return response; + } + } + + public sealed class LimitedFactory : TestWebApplicationFactory + { + public TestClock Clock { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:CastPermits"] = "3", + ["ConceptVotes:CastWindow"] = "00:10:00", + ["ConceptVotes:MintPermits"] = "2", + ["ConceptVotes:MintWindow"] = "01:00:00", + ["ConceptVotes:NetworkTagSecret"] = "rate-limit-test-tag-secret-0123456789" + })); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddTransient(); + }); + } + } + + public sealed class TestClock : TimeProvider + { + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } + + // The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline. + private sealed class PeerFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + context.Connection.RemoteIpAddress = + IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "127.0.0.1"); + return nextMiddleware(context); + }); + next(app); + }; + } +} diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs new file mode 100644 index 0000000..0343c7f --- /dev/null +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -0,0 +1,93 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Tests; + +public sealed class FixedWindowBudgetTests +{ + private static readonly TimeSpan Window = TimeSpan.FromSeconds(60); + + [Fact] + public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, clock); + + Assert.True(budget.TryAcquire("a", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(budget.TryAcquire("a", out _)); + Assert.False(budget.TryAcquire("a", out var wait)); + + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.True(budget.TryAcquire("b", out _)); + } + + [Fact] + public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, clock); + Assert.True(budget.TryAcquire("a", out _)); + + clock.Advance(Window - TimeSpan.FromTicks(1)); + Assert.False(budget.TryAcquire("a", out var wait)); + Assert.Equal(TimeSpan.FromTicks(1), wait); + + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(budget.TryAcquire("a", out _)); + Assert.False(budget.TryAcquire("a", out wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_denied_attempt_does_not_extend_the_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, clock); + budget.TryAcquire("a", out _); + for (var i = 0; i < 5; i++) + { + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.False(budget.TryAcquire("a", out _)); + } + + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(budget.TryAcquire("a", out _)); + } + + [Fact] + public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, clock); + for (var i = 0; i < 100; i++) + { + budget.TryAcquire("k" + i, out _); + } + + Assert.Equal(100, budget.TrackedKeys); + clock.Advance(Window); + budget.TryAcquire("fresh", out _); + + Assert.Equal(1, budget.TrackedKeys); + } + + [Theory] + [InlineData(0, 60)] + [InlineData(-1, 60)] + [InlineData(1, 0)] + [InlineData(1, -5)] + public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds) + { + Assert.ThrowsAny( + () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), new Clock())); + } + + private sealed class Clock : TimeProvider + { + private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 7d55439..959c883 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -185,6 +185,50 @@ public sealed class HostTrustTests : IClassFixture 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( + () => Validate(keysPath: "/keys", environment: environment, ipv4Length: 33)); + Assert.Contains("ConceptVotes:IPv4PrefixLength", v4.Message); + Assert.DoesNotContain("IPv6PrefixLength", v4.Message); + + var v6 = Assert.Throws( + () => 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(() => 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("*")] [InlineData("")] @@ -267,7 +311,11 @@ public sealed class HostTrustTests : IClassFixture string? tagSecret = ValidTagSecret, int ipv4Length = 32, int ipv6Length = 56, - string[]? allowedOrigins = null) + string[]? allowedOrigins = null, + int castPermits = 30, + TimeSpan? castWindow = null, + int mintPermits = 5, + TimeSpan? mintWindow = null) { var values = new Dictionary { @@ -295,7 +343,11 @@ public sealed class HostTrustTests : IClassFixture NetworkTagSecret = tagSecret ?? string.Empty, IPv4PrefixLength = ipv4Length, IPv6PrefixLength = ipv6Length, - AllowedOrigins = allowedOrigins ?? [] + AllowedOrigins = allowedOrigins ?? [], + CastPermits = castPermits, + CastWindow = castWindow ?? TimeSpan.FromMinutes(10), + MintPermits = mintPermits, + MintWindow = mintWindow ?? TimeSpan.FromHours(1) })).Validate(); } diff --git a/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs new file mode 100644 index 0000000..ea7558b --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests.Support; + +public sealed class CapturingLoggerProvider : ILoggerProvider +{ + private readonly List _lines = []; + + public IReadOnlyList Lines + { + get + { + lock (_lines) + { + return _lines.ToArray(); + } + } + } + + public ILogger CreateLogger(string categoryName) => new Capture(this); + + public void Dispose() + { + } + + private sealed class Capture(CapturingLoggerProvider owner) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + lock (owner._lines) + { + owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs b/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs index f63fc13..298642e 100644 --- a/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs +++ b/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs @@ -21,11 +21,14 @@ public sealed class TestAuthHandler( return Task.FromResult(AuthenticateResult.NoResult()); } + // A test may sign in as another account by naming its GitHub id and login. + var gitHubId = Request.Headers["X-Test-GitHub-Id"].FirstOrDefault() ?? "12345"; + var login = Request.Headers["X-Test-GitHub-Login"].FirstOrDefault() ?? "test-admin"; var claims = new[] { - new Claim(ClaimTypes.NameIdentifier, "12345"), - new Claim(ClaimTypes.Name, "test-admin"), - new Claim(BifrostClaimTypes.GitHubLogin, "test-admin"), + new Claim(ClaimTypes.NameIdentifier, gitHubId), + new Claim(ClaimTypes.Name, login), + new Claim(BifrostClaimTypes.GitHubLogin, login), new Claim(BifrostClaimTypes.DisplayName, "Test Admin") }; diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index ed3c4ed..7672a76 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -34,6 +34,8 @@ public class TestWebApplicationFactory : WebApplicationFactory ["GitHubApp:PrivateKeyPem"] = "test-private-key", ["GitHubApp:AppId"] = "1", ["Bridge:LocalBridgeToken"] = "test-bridge-token", + ["ConceptVotes:CastPermits"] = "100000", + ["ConceptVotes:MintPermits"] = "100000", ["Host:PublicBaseUrl"] = "https://localhost", ["Host:ExpectedHost"] = "localhost", ["Host:RequireStrictHostValidation"] = "false",