From a8c3ecf49539c3259dc6f865c95e1f3eb3c751e4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 17:32:01 +0200 Subject: [PATCH] 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 --- .../Configuration/ConceptVoteOptions.cs | 13 ++- .../ConceptVotes/ConceptVoteRateLimits.cs | 4 +- .../Features/Shared/FixedWindowBudget.cs | 68 ++++++----- .../Shared/StartupConfigurationValidator.cs | 5 + src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 23 +++- .../ConceptVoteRateLimitTests.cs | 44 ++++++- .../FixedWindowBudgetTests.cs | 109 ++++++++++++++---- tests/Bifrost.Web.Tests/HostTrustTests.cs | 18 ++- 8 files changed, 221 insertions(+), 63 deletions(-) diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index 41a866e..c5e5701 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -28,12 +28,19 @@ public sealed class ConceptVoteOptions 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; + // Mint: a new device cookie is a new voter identity, the cheapest stuffing lever. Anonymous visitors share an + // address behind carrier, school and office NAT (the research note records it), so 30 new devices per hour per + // address (operator ruling concept-mill-votes:ruling:mint-budget-30-per-hour); per-device and per-prefix cast + // limits still bound stuffing. + public int MintPermits { get; init; } = 30; public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1); + // The most partitions each budget holds at once. At the cap a partition not yet held is refused with a 429 until + // the oldest window ends; held partitions are never evicted, so a rotating caller cannot escape a spent budget. + // 100000 partitions is a few tens of MB; a real audience is far below it. + public int MaxTrackedKeys { get; init; } = 100_000; + // 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 index aac9802..d662b8a 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptVoteRateLimits.cs @@ -12,8 +12,8 @@ namespace Bifrost.Web.Features.ConceptVotes; 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); + private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock); + private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, options.Value.MaxTrackedKeys, clock); public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) => cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait) diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs index abcaa0f..c002f03 100644 --- a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -1,28 +1,37 @@ 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. +// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected +// clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a +// window. Windows all have one length, so they end in the order they began: a queue of window starts expires them from +// the front and the table never holds a key past its window. The table holds at most maxKeys keys; a new key arriving +// at the cap is denied until the oldest window ends, with the true wait, so a rotating caller cannot evict a spent key +// to escape its budget and the refusal is a 429 that says when to retry, never a silent drop. +// 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 int maxKeys; private readonly TimeProvider clock; + private readonly long origin; private readonly Dictionary entries = new(StringComparer.Ordinal); + private readonly Queue byStart = new(); private readonly object gate = new(); - private DateTimeOffset nextSweep = DateTimeOffset.MinValue; - public FixedWindowBudget(int permits, TimeSpan window, TimeProvider clock) + public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock) { ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1); ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxKeys, 1); this.permits = permits; this.window = window; + this.maxKeys = maxKeys; this.clock = clock; + origin = clock.GetTimestamp(); } - // How many keys are held now: bounded by the distinct keys seen in one window, not by every key ever seen. + // How many keys are held now: the keys whose window is still open, never more than maxKeys. public int TrackedKeys { get @@ -34,44 +43,47 @@ public sealed class FixedWindowBudget } } - // Takes one permit for the key. When none is left, returns false and how long until its window ends. + // Takes one permit for the key. When none is left, or the table is full and the key is new, returns false and how + // long until a window ends and the answer can change. public bool TryAcquire(string key, out TimeSpan retryAfter) { - var now = clock.GetUtcNow(); lock (gate) { - if (now >= nextSweep) + var now = clock.GetElapsedTime(origin); + while (byStart.TryPeek(out var oldest) && entries[oldest].Start + window <= now) { - foreach (var expired in entries.Where(x => x.Value.Start + window <= now).Select(x => x.Key).ToList()) + entries.Remove(byStart.Dequeue()); + } + + if (entries.TryGetValue(key, out var entry)) + { + if (entry.Count >= permits) { - entries.Remove(expired); + retryAfter = entry.Start + window - now; + return false; } - 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; + if (entries.Count >= maxKeys) + { + retryAfter = entries[byStart.Peek()].Start + window - now; + return false; + } + + entries[key] = new Entry(now); + byStart.Enqueue(key); + retryAfter = TimeSpan.Zero; + return true; } } - private sealed class Entry(DateTimeOffset start) + private sealed class Entry(TimeSpan start) { - public DateTimeOffset Start { get; } = start; + public TimeSpan 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 676e628..98b8dc7 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -40,6 +40,11 @@ public sealed class StartupConfigurationValidator( 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); diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index a06c3b3..2e11b1f 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -77,7 +77,7 @@ public sealed class VoteModel( { var actor = await actorAccessor.GetAsync(cancellationToken); var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter)) is { } throttled) + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled) { return throttled; } @@ -100,7 +100,7 @@ public sealed class VoteModel( var actor = await actorAccessor.GetAsync(cancellationToken); var client = HttpContext.Connection.RemoteIpAddress; var voter = ResolveVoter(actor); - if (Throttled(limits.DenyCast(client, voter)) is { } throttled) + if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled) { return throttled; } @@ -112,7 +112,7 @@ public sealed class VoteModel( if (voter is null) { - if (Throttled(limits.DenyMint(client)) is { } mintThrottled) + if (Throttled(limits.DenyMint(client), MintRefusal) is { } mintThrottled) { return mintThrottled; } @@ -132,8 +132,14 @@ public sealed class VoteModel( 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) + // A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and + // repeats nothing of the request. A refused new device is told it can sign in, which has a budget of its own. + private const string CastRefusal = "You are voting too fast. Please wait a little before voting again."; + + private const string MintRefusal = + "Too many new voters have voted from your network recently. You can sign in to vote now, or try again later."; + + private IActionResult? Throttled(TimeSpan? retryAfter, string refusal) { if (retryAfter is not { } wait) { @@ -141,7 +147,12 @@ public sealed class VoteModel( } Response.Headers.RetryAfter = ((int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); - return new StatusCodeResult(StatusCodes.Status429TooManyRequests); + return new ContentResult + { + StatusCode = StatusCodes.Status429TooManyRequests, + ContentType = "text/plain; charset=utf-8", + Content = refusal + }; } private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index b2beb0b..8e533f4 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -206,6 +206,10 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture 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 void Defaults_allow_thirty_new_devices_an_hour_per_address() + { + var defaults = new Bifrost.Web.Configuration.ConceptVoteOptions(); + + Assert.Equal(30, defaults.MintPermits); + Assert.Equal(TimeSpan.FromHours(1), defaults.MintWindow); + Assert.Equal(100_000, defaults.MaxTrackedKeys); + } + + [Fact] + public void Both_budgets_hold_at_most_the_configured_number_of_partitions_and_refuse_a_new_one_at_the_cap() + { + var clock = new TestClock(); + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1 }), + clock); + + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + Assert.NotNull(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null)); + Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1"))); + Assert.NotNull(limits.DenyMint(IPAddress.Parse("198.51.100.2"))); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + } + [Fact] public async Task Reads_are_not_under_the_vote_budgets() { @@ -387,10 +418,19 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture TimeSpan.TicksPerSecond; public override DateTimeOffset GetUtcNow() => _now; - public void Advance(TimeSpan by) => _now += by; + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _now += by; + _ticks += by.Ticks; + } } // The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline. diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs index 0493a03..5d1c3b7 100644 --- a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -5,12 +5,13 @@ namespace Bifrost.Web.Tests; public sealed class FixedWindowBudgetTests { private static readonly TimeSpan Window = TimeSpan.FromSeconds(60); + private const int Cap = 1000; [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); + var budget = new FixedWindowBudget(2, Window, Cap, clock); Assert.True(budget.TryAcquire("a", out _)); clock.Advance(TimeSpan.FromSeconds(10)); @@ -25,7 +26,7 @@ public sealed class FixedWindowBudgetTests 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); + var budget = new FixedWindowBudget(1, Window, Cap, clock); Assert.True(budget.TryAcquire("a", out _)); clock.Advance(Window - TimeSpan.FromTicks(1)); @@ -42,7 +43,7 @@ public sealed class FixedWindowBudgetTests public void A_denied_attempt_does_not_extend_the_window() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); budget.TryAcquire("a", out _); for (var i = 0; i < 5; i++) { @@ -55,16 +56,16 @@ public sealed class FixedWindowBudgetTests } [Fact] - public void A_sweep_drops_only_expired_windows_and_a_live_one_keeps_counting_to_its_exact_end() + public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); budget.TryAcquire("a", out _); clock.Advance(TimeSpan.FromSeconds(30)); budget.TryAcquire("b", out _); clock.Advance(TimeSpan.FromSeconds(30)); - // The sweep due at 60 s drops a, which ended, and keeps b, which has 30 s left. + // At 60 s a has ended and b has 30 s left. budget.TryAcquire("c", out _); Assert.Equal(2, budget.TrackedKeys); Assert.False(budget.TryAcquire("b", out var wait)); @@ -77,29 +78,79 @@ public sealed class FixedWindowBudgetTests } [Fact] - public void Sweeping_is_at_most_once_per_window_so_a_flood_of_keys_costs_one_pass_not_one_per_request() + public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after() { var clock = new Clock(); - var budget = new FixedWindowBudget(1, Window, clock); + var budget = new FixedWindowBudget(1, Window, Cap, clock); budget.TryAcquire("a", out _); - clock.Advance(TimeSpan.FromSeconds(30)); + clock.Advance(TimeSpan.FromSeconds(10)); + clock.StepWall(-TimeSpan.FromHours(1)); + + Assert.False(budget.TryAcquire("a", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + clock.Advance(TimeSpan.FromSeconds(50)); + Assert.True(budget.TryAcquire("a", out _)); + Assert.Equal(1, budget.TrackedKeys); + } + + [Fact] + public void A_wall_clock_step_forward_does_not_reset_a_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + budget.TryAcquire("a", out _); + clock.StepWall(TimeSpan.FromHours(1)); + + Assert.False(budget.TryAcquire("a", out var wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_new_key_at_the_cap_is_denied_until_the_oldest_window_ends_and_a_held_key_is_never_evicted() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, 2, clock); + budget.TryAcquire("a", out _); + clock.Advance(TimeSpan.FromSeconds(20)); budget.TryAcquire("b", out _); - clock.Advance(TimeSpan.FromSeconds(31)); - budget.TryAcquire("c", out _); + clock.Advance(TimeSpan.FromSeconds(10)); + + Assert.False(budget.TryAcquire("c", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(30), wait); Assert.Equal(2, budget.TrackedKeys); - // b ended at 90 s but the next sweep is not due until 121 s. - clock.Advance(TimeSpan.FromSeconds(39)); - budget.TryAcquire("d", out _); + // Rotating new keys cannot push a spent key out to get its budget back. + for (var i = 0; i < 10; i++) + { + Assert.False(budget.TryAcquire("rot" + i, out _)); + } - Assert.Equal(3, budget.TrackedKeys); + Assert.False(budget.TryAcquire("a", out wait)); + Assert.Equal(TimeSpan.FromSeconds(30), wait); + + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(budget.TryAcquire("c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(budget.TryAcquire("b", out wait)); + Assert.Equal(TimeSpan.FromSeconds(20), wait); + } + + [Fact] + public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 1, clock); + Assert.True(budget.TryAcquire("a", out _)); + + Assert.True(budget.TryAcquire("a", out _)); + Assert.False(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); + var budget = new FixedWindowBudget(1, Window, Cap, clock); for (var i = 0; i < 100; i++) { budget.TryAcquire("k" + i, out _); @@ -120,15 +171,33 @@ public sealed class FixedWindowBudgetTests public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds) { Assert.ThrowsAny( - () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), new Clock())); + () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock())); } + [Fact] + public void A_cap_below_one_is_refused() + { + Assert.Throws(() => new FixedWindowBudget(1, Window, 0, new Clock())); + } + + // Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would. private sealed class Clock : TimeProvider { - private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + private long _ticks = 5_000_000; - public override DateTimeOffset GetUtcNow() => _now; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; - public void Advance(TimeSpan by) => _now += by; + public override DateTimeOffset GetUtcNow() => _wall; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _wall += by; + _ticks += by.Ticks; + } + + public void StepWall(TimeSpan by) => _wall += by; } } diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 959c883..54c0128 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -229,6 +229,18 @@ public sealed class HostTrustTests : IClassFixture 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( + () => Validate(keysPath: "/keys", environment: environment, maxTrackedKeys: 0)); + + Assert.Contains("ConceptVotes:MaxTrackedKeys", refusal.Message); + } + [Theory] [InlineData("*")] [InlineData("")] @@ -315,7 +327,8 @@ public sealed class HostTrustTests : IClassFixture int castPermits = 30, TimeSpan? castWindow = null, int mintPermits = 5, - TimeSpan? mintWindow = null) + TimeSpan? mintWindow = null, + int maxTrackedKeys = 100_000) { var values = new Dictionary { @@ -347,7 +360,8 @@ public sealed class HostTrustTests : IClassFixture CastPermits = castPermits, CastWindow = castWindow ?? TimeSpan.FromMinutes(10), MintPermits = mintPermits, - MintWindow = mintWindow ?? TimeSpan.FromHours(1) + MintWindow = mintWindow ?? TimeSpan.FromHours(1), + MaxTrackedKeys = maxTrackedKeys })).Validate(); }