From af3b712b266d981fc38cf079775a8e0d953e340b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:48:04 +0200 Subject: [PATCH 01/11] VoterKeyResolver: the one reader and writer of the bifrost_voter device cookie Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 4 ++ .../Features/ConceptVotes/VoterKeyResolver.cs | 55 +++++++++++++++++++ src/Bifrost.Web/Program.cs | 2 + 3 files changed, 61 insertions(+) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index da13c4a..a5e0781 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -24,4 +24,8 @@ public sealed class ConceptVoteOptions // 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. public string[] AllowedOrigins { get; init; } = []; + + // The Mill page the vote page links back to; the concept's key is appended as the anchor. Production is the + // Aetheria site's page, so a deploy overrides nothing unless the page moves (ConceptVotes__MillPageUrl). + public string MillPageUrl { get; init; } = "https://aetheria.gamecult.org/Game-Design/Concept-Mill"; } diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs new file mode 100644 index 0000000..ce9fbec --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The only reader and writer of the bifrost_voter cookie. It supplies a device id and nothing else: VoterIdentity.For +// decides the voter key and class. The cookie holds a protected random id, never a count, class or account. +public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection) +{ + public const string CookieName = "bifrost_voter"; + public const string CookiePath = "/mill"; + public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365); + + private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; + + private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose); + + // The device id, or null when there is no cookie or it does not unprotect under this key ring and purpose + // (tampered, truncated, a bare id, or minted elsewhere). Nothing about a refused value is kept or reported. + public string? TryRead(HttpContext context) + { + if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || string.IsNullOrEmpty(token)) + { + return null; + } + + try + { + return protector.Unprotect(token); + } + catch (CryptographicException) + { + return null; + } + } + + // Sets a fresh device cookie on this response and returns its device id. Called only from a cast. + public string Mint(HttpContext context) + { + var deviceId = Guid.NewGuid().ToString("N"); + context.Response.Cookies.Append( + CookieName, + protector.Protect(deviceId), + new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = CookiePath, + MaxAge = CookieLifetime, + IsEssential = true + }); + return deviceId; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bea0beb..2101abb 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -62,6 +62,7 @@ builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); + options.Conventions.AllowAnonymousToFolder("/Mill"); }); builder.Services.ConfigureHttpJsonOptions(options => { @@ -103,6 +104,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); From f5a1f387cae702a380f1ec8356606a677dd4861f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:48:04 +0200 Subject: [PATCH 02/11] Mill vote page: anonymous cast, change and retract, cookie minted only by a cast Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Pages/Mill/Vote.cshtml | 56 ++ src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 96 +++ .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 568 ++++++++++++++++++ 3 files changed, 720 insertions(+) create mode 100644 src/Bifrost.Web/Pages/Mill/Vote.cshtml create mode 100644 src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptVotePageTests.cs diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml b/src/Bifrost.Web/Pages/Mill/Vote.cshtml new file mode 100644 index 0000000..daa82b1 --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml @@ -0,0 +1,56 @@ +@page "/mill/{collection}/{key}" +@model Bifrost.Web.Pages.Mill.VoteModel +@{ + ViewData["Title"] = Model.Subject.Title; + var stance = Model.Subject.YourStance; +} + +
+ Back to the Concept Mill +

@Model.Subject.Title

+ +
+
Everyone
+
@Model.Subject.Tally.Up up, @Model.Subject.Tally.Down down
+
Members
+
@Model.Subject.Tally.Members.Up up, @Model.Subject.Tally.Members.Down down
+
+ +

+ @if (stance is null) + { + You have not voted on this concept. + } + else + { + Your vote: @(stance == Bifrost.Web.Domain.ConceptStance.Up ? "up" : "down"). + } +

+ +
+
+ +
+
+ +
+ @if (stance is not null) + { +
+ +
+ } +
+ + @if (Model.SignedIn) + { +

Your vote is kept against your Bifrost account. No cookie is set.

+ } + else + { +

+ Voting saves one small cookie on this device. Its only purpose is to keep your one vote yours so you can + change or retract it; it lasts a year and carries nothing else. +

+ } +
diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs new file mode 100644 index 0000000..2182f65 --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -0,0 +1,96 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Pages.Mill; + +// The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the +// voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. +public sealed class VoteModel( + ICurrentBifrostActorAccessor actorAccessor, + ConceptAppraisalService appraisals, + VoterKeyResolver deviceCookie, + IOptions options) : PageModel +{ + public ConceptSubjectView Subject { get; private set; } = null!; + + public bool SignedIn { get; private set; } + + public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key); + + public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor, mint: false); + SignedIn = voter?.UserAccountId is not null; + return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound(); + } + + public Task OnPostUpAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Up, cancellationToken); + + public Task OnPostDownAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Down, cancellationToken); + + public async Task OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + 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 }); + } + + private async Task CastAsync( + string collection, + string key, + ConceptStance stance, + CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) + { + return NotFound(); + } + + var voter = ResolveVoter(actor, mint: true); + var result = await appraisals.CastAsync( + collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken); + return result is CastResult.UnknownSubject or CastResult.RetiredSubject + ? NotFound() + : RedirectToPage(new { collection, key }); + } + + // 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) + { + var voter = VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); + if (voter is null && mint) + { + voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + } + + return voter; + } + + private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) + { + var view = await appraisals.GetSubjectAsync(collection, key, voter, cancellationToken); + if (view is null) + { + return false; + } + + Subject = view; + return true; + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs new file mode 100644 index 0000000..25f63eb --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -0,0 +1,568 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +public sealed partial class ConceptVotePageTests : IClassFixture +{ + private const string CookieName = "bifrost_voter"; + private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; + + private readonly TestWebApplicationFactory _factory; + + public ConceptVotePageTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Anonymous_visitor_can_cast_change_and_retract() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + var setCookie = SetCookieHeader(cast, CookieName); + Assert.NotNull(setCookie); + var attributes = setCookie!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/mill", attributes); + Assert.Contains("max-age=31536000", attributes); + + using (var page = await visitor.Get(collection, "alpha")) + { + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("0 up, 0 down", members); + } + + var firstRow = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Up, firstRow.Stance); + Assert.Equal(VoterClass.Anonymous, firstRow.VoterClass); + + // The cookie from the first cast is the voter: casting again changes the row and mints nothing. + using var change = await visitor.Post(collection, "alpha", "Down"); + Assert.Equal(HttpStatusCode.Redirect, change.StatusCode); + Assert.Null(SetCookieHeader(change, CookieName)); + var changed = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Down, changed.Stance); + Assert.Equal(firstRow.VoterKey, changed.VoterKey); + + using var retract = await visitor.Post(collection, "alpha", "Retract"); + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Fact] + public async Task Get_never_sets_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var first = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Null(SetCookieHeader(first, CookieName)); + } + + using (var retract = await visitor.Post(collection, "alpha", "Retract")) + { + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + } + + Assert.False(visitor.Jar.ContainsKey(CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + using var later = await visitor.Get(collection, "alpha"); + Assert.Null(SetCookieHeader(later, CookieName)); + } + + [Fact] + public async Task Signed_in_vote_is_keyed_to_the_account() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: false); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.Null(SetCookieHeader(cast, CookieName)); + var row = Assert.Single(await Rows(collection, "alpha")); + Assert.StartsWith("account:", row.VoterKey, StringComparison.Ordinal); + Assert.Equal(VoterClass.Member, row.VoterClass); + Assert.NotNull(row.UserAccountId); + + using var page = await visitor.Get(collection, "alpha"); + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Null(SetCookieHeader(page, CookieName)); + } + + [Fact] + public async Task Cast_without_antiforgery_token_is_refused() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var noToken = await visitor.Post(collection, "alpha", "Up", token: Token.Omitted); + using var noCookie = await visitor.Post(collection, "alpha", "Up", token: Token.WithoutAntiforgeryCookie); + using var garbage = await visitor.Post(collection, "alpha", "Up", token: Token.Garbage); + + Assert.Equal(HttpStatusCode.BadRequest, noToken.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, noCookie.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, garbage.StatusCode); + Assert.Null(SetCookieHeader(noToken, CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + } + + public enum Forgery + { + OneCharacterChanged, + BareDeviceId, + SameKeyRingOtherPurpose, + OtherKeyRingSamePurpose + } + + [Theory] + [InlineData(Forgery.OneCharacterChanged)] + [InlineData(Forgery.BareDeviceId)] + [InlineData(Forgery.SameKeyRingOtherPurpose)] + [InlineData(Forgery.OtherKeyRingSamePurpose)] + public async Task Tampered_or_foreign_cookie_is_ignored_and_a_new_one_minted(Forgery kind) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var victim = NewVisitor(anonymous: true); + using (await victim.Post(collection, "alpha", "Up")) + { + } + + var genuine = victim.Jar[CookieName]; + var victimRow = Assert.Single(await Rows(collection, "alpha")); + var deviceId = victimRow.VoterKey["device:".Length..]; + var forged = Forge(kind, genuine, deviceId); + Assert.NotEqual(genuine, forged); + if (kind is not Forgery.BareDeviceId) + { + Assert.Equal(genuine.Length, forged.Length); + Assert.Matches("^[A-Za-z0-9_-]+$", forged); + } + + var attacker = NewVisitor(anonymous: true); + attacker.Jar[CookieName] = forged; + using var cast = await attacker.Post(collection, "alpha", "Down"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + Assert.NotEqual(forged, attacker.Jar[CookieName]); + Assert.NotEqual(genuine, attacker.Jar[CookieName]); + var rows = await Rows(collection, "alpha"); + Assert.Equal(2, rows.Count); + var victimNow = Assert.Single(rows, x => x.VoterKey == victimRow.VoterKey); + Assert.Equal(ConceptStance.Up, victimNow.Stance); + var attackerRow = Assert.Single(rows, x => x.VoterKey != victimRow.VoterKey); + Assert.Equal(ConceptStance.Down, attackerRow.Stance); + Assert.Equal(VoterClass.Anonymous, attackerRow.VoterClass); + } + + [Fact] + public async Task A_forged_cookie_value_is_echoed_by_no_response_and_no_log_line() + { + const string canary = "CANARY-forged-voter-cookie-7f3a"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureLogging(l => l.AddProvider(logs))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + visitor.Jar[CookieName] = canary; + + using var page = await visitor.Get(collection, "alpha"); + using var cast = await visitor.Post(collection, "alpha", "Up"); + + foreach (var response in new[] { page, cast }) + { + Assert.DoesNotContain(canary, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.DoesNotContain( + canary, + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))), + StringComparison.Ordinal); + } + + Assert.NotEmpty(logs.Lines); + Assert.DoesNotContain(logs.Lines, line => line.Contains(canary, StringComparison.Ordinal)); + } + + [Fact] + public async Task Unknown_or_retired_key_is_404() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "gone"); + await Retire(collection, "gone"); + var visitor = NewVisitor(anonymous: true); + + foreach (var (c, k) in new[] { (collection, "gone"), (collection, "never-registered"), (NewCollection(), "alpha") }) + { + using var get = await visitor.Get(c, k); + Assert.Equal(HttpStatusCode.NotFound, get.StatusCode); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + using var post = await visitor.Post(c, k, handler, tokenFrom: (collection, "alpha")); + Assert.Equal(HttpStatusCode.NotFound, post.StatusCode); + Assert.Null(SetCookieHeader(post, CookieName)); + } + } + + Assert.Empty(await Rows(collection, "gone")); + Assert.Empty(await Rows(collection, "alpha")); + Assert.False(visitor.Jar.ContainsKey(CookieName)); + } + + [Fact] + public async Task Page_shows_counts_by_class_and_no_voter_identity() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var memberId = Guid.NewGuid(); + var accountId = Guid.NewGuid(); + await AddRows(collection, "alpha", + Row("device:canary-device-id-4b1d", VoterClass.Anonymous, ConceptStance.Up, null, "canarytag-aaaa"), + Row("device:canary-device-id-5c2e", VoterClass.Anonymous, ConceptStance.Down, null, "canarytag-bbbb"), + Row("account:" + memberId.ToString("N"), VoterClass.Member, ConceptStance.Up, memberId, "canarytag-cccc"), + Row("account:" + accountId.ToString("N"), VoterClass.Account, ConceptStance.Down, accountId, "canarytag-dddd")); + var visitor = NewVisitor(anonymous: true); + using (await visitor.Post(collection, "alpha", "Up")) + { + } + + using var page = await visitor.Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + + var (everyone, members) = Counts(html); + Assert.Equal("3 up, 2 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Contains("Your vote: up.", html, StringComparison.Ordinal); + + var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => !x.VoterKey.Contains("canary")); + var forbidden = new List + { + "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, visitorRow.NetworkTag, + visitor.Jar[CookieName], + memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D") + }; + foreach (var value in forbidden) + { + Assert.DoesNotContain(value, html, StringComparison.OrdinalIgnoreCase); + } + + Assert.True(page.Headers.CacheControl is { NoStore: true } or { NoCache: true }); + + var signedIn = NewVisitor(anonymous: false); + using var signedInPage = await signedIn.Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + using var signedInCast = await signedIn.Post(collection, "alpha", "Down"); + var accountRow = Assert.Single( + await Rows(collection, "alpha"), + x => x.UserAccountId is { } id && id != memberId && id != accountId); + using var afterCast = await signedIn.Get(collection, "alpha"); + var afterHtml = await afterCast.Content.ReadAsStringAsync(); + Assert.Contains("Your vote: down.", afterHtml, StringComparison.Ordinal); + foreach (var text in new[] { signedInHtml, afterHtml }) + { + Assert.DoesNotContain("account:", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("N"), text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("D"), text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.NetworkTag, text, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Cookie_notice_is_shown_to_anonymous_visitors_and_the_page_links_back_to_the_mill() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var anonymousPage = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await anonymousPage.Content.ReadAsStringAsync(); + using var signedInPage = await NewVisitor(anonymous: false).Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + + Assert.Contains("only purpose is to keep your one vote yours", html, StringComparison.Ordinal); + Assert.Contains("lasts a year", html, StringComparison.Ordinal); + Assert.Contains("carries nothing else", html, StringComparison.Ordinal); + Assert.DoesNotContain("only purpose is to keep your one vote yours", signedInHtml, StringComparison.Ordinal); + Assert.Contains( + "href=\"https://aetheria.gamecult.org/Game-Design/Concept-Mill#alpha\"", + html, + StringComparison.Ordinal); + } + + [Fact] + public async Task Page_loads_no_script_and_posts_with_plain_forms() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var page = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + var main = html[html.IndexOf("", StringComparison.Ordinal)]; + + Assert.DoesNotContain(" kind switch + { + Forgery.OneCharacterChanged => genuine[..(genuine.Length / 2)] + + (genuine[genuine.Length / 2] == 'A' ? 'B' : 'A') + + genuine[(genuine.Length / 2 + 1)..], + Forgery.BareDeviceId => deviceId, + Forgery.SameKeyRingOtherPurpose => _factory.Services.GetRequiredService() + .CreateProtector(Purpose + ".other").Protect(deviceId), + Forgery.OtherKeyRingSamePurpose => new EphemeralDataProtectionProvider() + .CreateProtector(Purpose).Protect(deviceId), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string NewCollection() => "c" + Guid.NewGuid().ToString("N"); + + private Visitor NewVisitor(bool anonymous, WebApplicationFactory? factory = null) => + new((factory ?? _factory).CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri("https://localhost") + }), anonymous); + + private static string? SetCookieHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal)) + : null; + + private static (string Everyone, string Members) Counts(string html) + { + var cells = DefinitionCell().Matches(html).Select(m => m.Groups[1].Value.Trim()).ToArray(); + Assert.Equal(2, cells.Length); + return (cells[0], cells[1]); + } + + [GeneratedRegex("
(.*?)
", RegexOptions.Singleline)] + private static partial Regex DefinitionCell(); + + [GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")] + private static partial Regex RequestToken(); + + private async Task AddSubject(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = key, + Title = key, + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + private async Task Retire(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + subject.RetiredAtUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + private static ConceptAppraisal Row(string key, VoterClass voterClass, ConceptStance stance, Guid? accountId, string tag) => + new() + { + VoterKey = key, + VoterClass = voterClass, + Stance = stance, + UserAccountId = accountId, + NetworkTag = tag, + CastAtUtc = DateTimeOffset.UtcNow + }; + + private async Task AddRows(string collection, string key, params ConceptAppraisal[] rows) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + foreach (var row in rows) + { + row.SubjectId = subject.Id; + db.ConceptAppraisals.Add(row); + } + + await db.SaveChangesAsync(); + } + + // A fresh DbContext per read: what a later request would see, not what this test's tracker remembers. + private async Task> Rows(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection && x.Subject.Key == key) + .ToListAsync(); + } + + public enum Token + { + Valid, + Omitted, + WithoutAntiforgeryCookie, + Garbage + } + + private sealed class Visitor(HttpClient client, bool anonymous) + { + public Dictionary Jar { get; } = new(StringComparer.Ordinal); + + public Task Get(string collection, string key) => + Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}")); + + // Reads a token from the page of tokenFrom (default: the page being posted to), then posts the handler. + public async Task Post( + string collection, + string key, + string handler, + Token token = Token.Valid, + (string Collection, string Key)? tokenFrom = null) + { + var from = tokenFrom ?? (collection, key); + string value; + using (var page = await Get(from.Collection, from.Key)) + { + var match = RequestToken().Match(await page.Content.ReadAsStringAsync()); + Assert.True(match.Success); + value = match.Groups[1].Value; + } + + var antiforgery = Jar + .Where(x => x.Key.StartsWith(".AspNetCore.Antiforgery", StringComparison.Ordinal)) + .ToList(); + if (token is Token.WithoutAntiforgeryCookie) + { + foreach (var cookie in antiforgery) + { + Jar.Remove(cookie.Key); + } + } + + var form = new List>(); + if (token is not Token.Omitted) + { + form.Add(new("__RequestVerificationToken", token is Token.Garbage ? "garbage" : value)); + } + + var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/{key}?handler={handler}") + { + Content = new FormUrlEncodedContent(form) + }; + var response = await Send(request); + foreach (var cookie in antiforgery) + { + Jar.TryAdd(cookie.Key, cookie.Value); + } + + return response; + } + + private async Task Send(HttpRequestMessage request) + { + if (anonymous) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + + if (Jar.Count > 0) + { + request.Headers.TryAddWithoutValidation( + "Cookie", + string.Join("; ", Jar.Select(x => x.Key + "=" + x.Value))); + } + + var response = await client.SendAsync(request); + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + { + foreach (var header in values) + { + var pair = header.Split(';')[0]; + var split = pair.IndexOf('='); + Jar[pair[..split]] = pair[(split + 1)..]; + } + } + + return response; + } + } + + private 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)); + } + } + } + } +} From e05e1bed819b8e14575fe40850e91e869024a506 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:51:24 +0200 Subject: [PATCH 03/11] ConceptVotePageTests: attach the log capture through ConfigureServices Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 25f63eb..15fbcf6 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -191,7 +191,7 @@ public sealed partial class ConceptVotePageTests : IClassFixture b.ConfigureLogging(l => l.AddProvider(logs))); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); var collection = NewCollection(); await AddSubject(collection, "alpha"); var visitor = NewVisitor(anonymous: true, factory); From f7ff76daa45feed0e4ac90536912a348ddb00c01 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:54:03 +0200 Subject: [PATCH 04/11] ConceptVotePageTests: find the visitor's own row by account-less key, drop the empty-tag assertion Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 15fbcf6..2e081c8 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -264,10 +264,10 @@ public sealed partial class ConceptVotePageTests : IClassFixture !x.VoterKey.Contains("canary")); + var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => x.UserAccountId is null && !x.VoterKey.Contains("canary")); var forbidden = new List { - "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, visitorRow.NetworkTag, + "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, visitor.Jar[CookieName], memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D") }; @@ -293,7 +293,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture Date: Sun, 11 Oct 2026 00:09:25 +0200 Subject: [PATCH 05/11] Vote page: cookieless CSRF (Origin / Sec-Fetch-Site) and a defined error for handler-less requests Drops antiforgery from the anonymous vote flow: no antiforgery cookie, no hidden token. CastRequestGuard is the one decider; VoteModel.OnPageHandlerSelected is the one gate and answers 4xx when no handler is selected (was a NullReferenceException 500). Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/CastRequestGuard.cs | 29 +++ src/Bifrost.Web/Pages/Mill/Vote.cshtml | 6 +- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 26 ++- .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 210 +++++++++++++----- 4 files changed, 209 insertions(+), 62 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs diff --git a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs new file mode 100644 index 0000000..6af4a6b --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs @@ -0,0 +1,29 @@ +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.Http; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous +// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead +// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser +// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header +// are all refused. Header values are compared, never echoed. +public static class CastRequestGuard +{ + public static bool Allows(HttpRequest request, BifrostHostOptions host) + { + if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site)) + { + return false; + } + + var origin = request.Headers.Origin; + if (origin.Count > 0) + { + return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal); + } + + var fetchSite = request.Headers["Sec-Fetch-Site"]; + return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal); + } +} diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml b/src/Bifrost.Web/Pages/Mill/Vote.cshtml index daa82b1..fe75338 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml @@ -28,15 +28,15 @@

-
+
-
+
@if (stance is not null) { -
+
} diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 2182f65..b25eec9 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -3,6 +3,7 @@ using Bifrost.Web.Domain; using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Options; @@ -10,11 +11,13 @@ namespace Bifrost.Web.Pages.Mill; // The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the // voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. +[IgnoreAntiforgeryToken] public sealed class VoteModel( ICurrentBifrostActorAccessor actorAccessor, ConceptAppraisalService appraisals, VoterKeyResolver deviceCookie, - IOptions options) : PageModel + IOptions options, + IOptions host) : PageModel { public ConceptSubjectView Subject { get; private set; } = null!; @@ -22,6 +25,27 @@ public sealed class VoteModel( public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key); + // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, + // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own + // origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. + public override void OnPageHandlerSelected(PageHandlerSelectedContext context) + { + var method = context.HttpContext.Request.Method; + if (context.HandlerMethod is null) + { + context.Result = new StatusCodeResult( + HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsPost(method) + ? StatusCodes.Status400BadRequest + : StatusCodes.Status405MethodNotAllowed); + return; + } + + if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) + { + context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); + } + } + public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 2e081c8..bf719b7 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -118,21 +118,144 @@ public sealed partial class ConceptVotePageTests : IClassFixture h.Key == "Set-Cookie")); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + Assert.Empty(await Rows(collection, "alpha")); + } + + [Theory] + [InlineData("same-origin", true)] + [InlineData("same-site", false)] + [InlineData("cross-site", false)] + [InlineData("none", false)] + [InlineData("Same-Origin", false)] + [InlineData("same-origin, cross-site", false)] + [InlineData(null, false)] + public async Task Cast_without_an_origin_header_needs_fetch_site_same_origin(string? fetchSite, bool accepted) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var response = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: fetchSite); + + Assert.Equal(accepted ? HttpStatusCode.Redirect : HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(accepted ? 1 : 0, (await Rows(collection, "alpha")).Count); + } + + [Fact] + public async Task Refusal_never_echoes_the_origin_or_fetch_site_values() + { + const string canary = "https://CANARY-origin-5e1c.example"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + + using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: canary); + using var byFetchSite = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: "CANARY-site-5e1c"); + + foreach (var response in new[] { byOrigin, byFetchSite }) + { + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var seen = await response.Content.ReadAsStringAsync() + + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("CANARY", seen, StringComparison.OrdinalIgnoreCase); + } + + Assert.DoesNotContain(logs.Lines, line => line.Contains("CANARY", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task A_page_view_sets_no_cookie_at_all_and_a_cast_sets_only_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var view = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, view.StatusCode); + Assert.False(view.Headers.Contains("Set-Cookie")); + var html = await view.Content.ReadAsStringAsync(); + Assert.DoesNotContain("__RequestVerificationToken", html, StringComparison.Ordinal); + } + + using var cast = await visitor.Post(collection, "alpha", "Up"); + var cookies = cast.Headers.GetValues("Set-Cookie").ToArray(); + Assert.Single(cookies); + Assert.StartsWith(CookieName + "=", cookies[0], StringComparison.Ordinal); + + using var later = await visitor.Get(collection, "alpha"); + Assert.False(later.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task A_request_that_selects_no_handler_is_a_client_error_and_never_a_500() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var noHandler = await visitor.Post(collection, "alpha", handler: null); + using var bogus = await visitor.Post(collection, "alpha", "CANARYQ9"); + using var put = await visitor.Post(collection, "alpha", "Up", method: HttpMethod.Put); + using var delete = await visitor.Post(collection, "alpha", null, method: HttpMethod.Delete); + using var getBogus = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=CANARYQ9")); + using var getUp = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=Up")); + + foreach (var response in new[] { noHandler, bogus, put, delete, getBogus, getUp }) + { + var status = (int)response.StatusCode; + Assert.InRange(status, 400, 499); + Assert.DoesNotContain("CANARYQ9", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.False(response.Headers.Contains("Set-Cookie")); + } - Assert.Equal(HttpStatusCode.BadRequest, noToken.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, noCookie.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, garbage.StatusCode); - Assert.Null(SetCookieHeader(noToken, CookieName)); Assert.Empty(await Rows(collection, "alpha")); } @@ -228,7 +351,7 @@ public sealed partial class ConceptVotePageTests : IClassFixture(.*?)", RegexOptions.Singleline)] private static partial Regex DefinitionCell(); - [GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")] - private static partial Regex RequestToken(); - private async Task AddSubject(string collection, string key) { using var scope = _factory.Services.CreateScope(); @@ -432,14 +552,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture Jar { get; } = new(StringComparer.Ordinal); @@ -447,54 +559,36 @@ public sealed partial class ConceptVotePageTests : IClassFixture Get(string collection, string key) => Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}")); - // Reads a token from the page of tokenFrom (default: the page being posted to), then posts the handler. - public async Task Post( + // A cookieless POST as a browser form would send it: Origin the site's own by default, null to omit it. + public Task Post( string collection, string key, - string handler, - Token token = Token.Valid, - (string Collection, string Key)? tokenFrom = null) + string? handler, + string? origin = SiteOrigin, + string? fetchSite = null, + HttpMethod? method = null) { - var from = tokenFrom ?? (collection, key); - string value; - using (var page = await Get(from.Collection, from.Key)) + var request = new HttpRequestMessage( + method ?? HttpMethod.Post, + $"/mill/{collection}/{key}" + (handler is null ? string.Empty : "?handler=" + handler)) { - var match = RequestToken().Match(await page.Content.ReadAsStringAsync()); - Assert.True(match.Success); - value = match.Groups[1].Value; - } - - var antiforgery = Jar - .Where(x => x.Key.StartsWith(".AspNetCore.Antiforgery", StringComparison.Ordinal)) - .ToList(); - if (token is Token.WithoutAntiforgeryCookie) - { - foreach (var cookie in antiforgery) - { - Jar.Remove(cookie.Key); - } - } - - var form = new List>(); - if (token is not Token.Omitted) - { - form.Add(new("__RequestVerificationToken", token is Token.Garbage ? "garbage" : value)); - } - - var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/{key}?handler={handler}") - { - Content = new FormUrlEncodedContent(form) + Content = new FormUrlEncodedContent([]) }; - var response = await Send(request); - foreach (var cookie in antiforgery) + if (origin is not null) { - Jar.TryAdd(cookie.Key, cookie.Value); + request.Headers.TryAddWithoutValidation("Origin", origin); } - return response; + if (fetchSite is not null) + { + request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", fetchSite); + } + + return Send(request); + } } - private async Task Send(HttpRequestMessage request) + public async Task Send(HttpRequestMessage request) { if (anonymous) { From 59d5dbd0688dad1b56184f389ccbffe56032ad80 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:11:01 +0200 Subject: [PATCH 06/11] Vote page: gate in the page filter's execution stage (the selected stage has no Result) Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index b25eec9..5912225 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -28,7 +28,7 @@ public sealed class VoteModel( // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own // origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. - public override void OnPageHandlerSelected(PageHandlerSelectedContext context) + public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) { var method = context.HttpContext.Request.Method; if (context.HandlerMethod is null) @@ -43,7 +43,10 @@ public sealed class VoteModel( if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) { context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); + return; } + + await next(); } public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) From 6554a1b0170ae21d47e370f66bb7ae5524a09919 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:11:50 +0200 Subject: [PATCH 07/11] ConceptVotePageTests: fix a stray brace Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index bf719b7..1cfb902 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -583,7 +583,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture Date: Sun, 11 Oct 2026 00:13:42 +0200 Subject: [PATCH 08/11] ConceptVotePageTests: close the Visitor class correctly Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 1cfb902..79d3645 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -583,9 +583,9 @@ public sealed partial class ConceptVotePageTests : IClassFixture Send(HttpRequestMessage request) { From d3a7611f2a3eda597d363b963cb2e73f45c88a07 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:15:13 +0200 Subject: [PATCH 09/11] Vote page: explicit no-store (the antiforgery write no longer supplies it), handler must match the request, empty Origin counts as present Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/CastRequestGuard.cs | 2 +- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs index 6af4a6b..ffd1b47 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs @@ -18,7 +18,7 @@ public static class CastRequestGuard } var origin = request.Headers.Origin; - if (origin.Count > 0) + if (request.Headers.ContainsKey("Origin")) { return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal); } diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 5912225..525811d 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -12,6 +12,7 @@ namespace Bifrost.Web.Pages.Mill; // The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the // voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. [IgnoreAntiforgeryToken] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public sealed class VoteModel( ICurrentBifrostActorAccessor actorAccessor, ConceptAppraisalService appraisals, @@ -40,6 +41,14 @@ public sealed class VoteModel( return; } + // The handler the request names must be the handler selected: GET ?handler=Up has no OnGetUp to run. + var named = context.HttpContext.Request.Query["handler"].ToString(); + if (string.IsNullOrEmpty(named) != string.IsNullOrEmpty(context.HandlerMethod.Name)) + { + context.Result = new StatusCodeResult(StatusCodes.Status400BadRequest); + return; + } + if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) { context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); From 75d2cedaa288c2323dbb654946db0ee3462612e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:18:06 +0200 Subject: [PATCH 10/11] ConceptVotePageTests: drop the empty-Origin case (HttpClient never sends an empty header) Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 79d3645..dd4b280 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -145,7 +145,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture h.Key == "Set-Cookie")); + Assert.False(response.Headers.Contains("Set-Cookie")); Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); } From 526102ec8a996a778699c775dbe91e4dc5be7c4d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:26:21 +0200 Subject: [PATCH 11/11] ConceptVotePageTests: pin a repeated Origin header and the exact client-error codes Co-Authored-By: Claude Opus 5.5 --- .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index dd4b280..1870b6f 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -165,6 +165,24 @@ public sealed partial class ConceptVotePageTests : IClassFixture