Merge hands/mill-votes-cut-5: the vote page, the device-cookie voter key and cookieless CSRF

Held by concept-mill-votes:verdict:cut-5.s2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 00:54:41 +02:00
commit 68214ced30
7 changed files with 961 additions and 0 deletions

View file

@ -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";
}

View file

@ -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 (request.Headers.ContainsKey("Origin"))
{
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);
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,56 @@
@page "/mill/{collection}/{key}"
@model Bifrost.Web.Pages.Mill.VoteModel
@{
ViewData["Title"] = Model.Subject.Title;
var stance = Model.Subject.YourStance;
}
<section class="panel">
<a class="back-link" href="@Model.MillPageLink">Back to the Concept Mill</a>
<h1>@Model.Subject.Title</h1>
<dl class="mill-counts">
<dt>Everyone</dt>
<dd>@Model.Subject.Tally.Up up, @Model.Subject.Tally.Down down</dd>
<dt>Members</dt>
<dd>@Model.Subject.Tally.Members.Up up, @Model.Subject.Tally.Members.Down down</dd>
</dl>
<p>
@if (stance is null)
{
<text>You have not voted on this concept.</text>
}
else
{
<text>Your vote: @(stance == Bifrost.Web.Domain.ConceptStance.Up ? "up" : "down").</text>
}
</p>
<div class="mill-actions">
<form method="post" asp-page-handler="Up" asp-antiforgery="false" hx-boost="false">
<button class="button" type="submit">Up</button>
</form>
<form method="post" asp-page-handler="Down" asp-antiforgery="false" hx-boost="false">
<button class="button" type="submit">Down</button>
</form>
@if (stance is not null)
{
<form method="post" asp-page-handler="Retract" asp-antiforgery="false" hx-boost="false">
<button class="button ghost" type="submit">Retract</button>
</form>
}
</div>
@if (Model.SignedIn)
{
<p class="muted">Your vote is kept against your Bifrost account. No cookie is set.</p>
}
else
{
<p class="muted">
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.
</p>
}
</section>

View file

@ -0,0 +1,132 @@
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.Filters;
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.
[IgnoreAntiforgeryToken]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class VoteModel(
ICurrentBifrostActorAccessor actorAccessor,
ConceptAppraisalService appraisals,
VoterKeyResolver deviceCookie,
IOptions<ConceptVoteOptions> options,
IOptions<BifrostHostOptions> host) : PageModel
{
public ConceptSubjectView Subject { get; private set; } = null!;
public bool SignedIn { get; private set; }
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 async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
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;
}
// 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);
return;
}
await next();
}
public async Task<IActionResult> 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<IActionResult> OnPostUpAsync(string collection, string key, CancellationToken cancellationToken) =>
CastAsync(collection, key, ConceptStance.Up, cancellationToken);
public Task<IActionResult> OnPostDownAsync(string collection, string key, CancellationToken cancellationToken) =>
CastAsync(collection, key, ConceptStance.Down, cancellationToken);
public async Task<IActionResult> 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<IActionResult> 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<bool> 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;
}
}

View file

@ -79,6 +79,7 @@ builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
options.Conventions.AllowAnonymousToFolder("/Mill");
});
builder.Services.ConfigureHttpJsonOptions(options =>
{
@ -117,6 +118,7 @@ builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<StripeWebhookService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ConceptAppraisalService>();
builder.Services.AddScoped<VoterKeyResolver>();
builder.Services.AddScoped<ConceptSubjectRegistry>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();

View file

@ -0,0 +1,683 @@
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<TestWebApplicationFactory>
{
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));
}
private const string SiteOrigin = "https://localhost";
[Fact]
public async Task Cast_from_the_sites_own_origin_is_accepted_and_writes_one_row()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: SiteOrigin);
Assert.Equal(HttpStatusCode.Redirect, byOrigin.StatusCode);
Assert.Single(await Rows(collection, "alpha"));
using var byFetchSite = await visitor.Post(collection, "alpha", "Down", origin: null, fetchSite: "same-origin");
Assert.Equal(HttpStatusCode.Redirect, byFetchSite.StatusCode);
Assert.Equal(ConceptStance.Down, Assert.Single(await Rows(collection, "alpha")).Stance);
}
[Theory]
[InlineData("https://localhost.evil.example")]
[InlineData("https://evil-localhost")]
[InlineData("https://sub.localhost")]
[InlineData("https://LOCALHOST")]
[InlineData("HTTPS://localhost")]
[InlineData("https://localhost:8443")]
[InlineData("http://localhost")]
[InlineData("null")]
[InlineData("https://localhost/")]
[InlineData("https://evil.example")]
public async Task Cast_from_any_other_origin_is_refused_even_when_fetch_site_says_same_origin(string origin)
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
foreach (var handler in new[] { "Up", "Down", "Retract" })
{
using var response = await visitor.Post(collection, "alpha", handler, origin: origin, fetchSite: "same-origin");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(SetCookieHeader(response, CookieName));
Assert.False(response.Headers.Contains("Set-Cookie"));
Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync());
}
Assert.Empty(await Rows(collection, "alpha"));
}
[Fact]
public async Task Cast_with_the_origin_header_repeated_is_refused_even_when_every_value_is_the_sites()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up")
{
Content = new FormUrlEncodedContent([])
};
request.Headers.TryAddWithoutValidation("Origin", [SiteOrigin, SiteOrigin]);
using var response = await visitor.Send(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
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"));
Assert.Equal(HttpStatusCode.BadRequest, noHandler.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, bogus.StatusCode);
Assert.Equal(HttpStatusCode.MethodNotAllowed, put.StatusCode);
Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, getBogus.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, getUp.StatusCode);
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.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.ConfigureServices(s => s.AddLogging(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);
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.UserAccountId is null && !x.VoterKey.Contains("canary"));
var forbidden = new List<string>
{
"device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey,
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);
}
}
[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("<main", StringComparison.Ordinal)..html.IndexOf("</main>", StringComparison.Ordinal)];
Assert.DoesNotContain("<script", main, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("hx-post", main, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("hx-get", main, StringComparison.OrdinalIgnoreCase);
Assert.Contains("method=\"post\"", main, StringComparison.Ordinal);
}
private string Forge(Forgery kind, string genuine, string deviceId) => 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<IDataProtectionProvider>()
.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<Program>? 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("<dd>(.*?)</dd>", RegexOptions.Singleline)]
private static partial Regex DefinitionCell();
private async Task AddSubject(string collection, string key)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<BifrostDbContext>();
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<BifrostDbContext>();
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<List<ConceptAppraisal>> Rows(string collection, string key)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return await db.ConceptAppraisals.AsNoTracking()
.Where(x => x.Subject!.Collection == collection && x.Subject.Key == key)
.ToListAsync();
}
private sealed class Visitor(HttpClient client, bool anonymous)
{
public Dictionary<string, string> Jar { get; } = new(StringComparer.Ordinal);
public Task<HttpResponseMessage> Get(string collection, string key) =>
Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}"));
// A cookieless POST as a browser form would send it: Origin the site's own by default, null to omit it.
public Task<HttpResponseMessage> Post(
string collection,
string key,
string? handler,
string? origin = SiteOrigin,
string? fetchSite = null,
HttpMethod? method = null)
{
var request = new HttpRequestMessage(
method ?? HttpMethod.Post,
$"/mill/{collection}/{key}" + (handler is null ? string.Empty : "?handler=" + handler))
{
Content = new FormUrlEncodedContent([])
};
if (origin is not null)
{
request.Headers.TryAddWithoutValidation("Origin", origin);
}
if (fetchSite is not null)
{
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", fetchSite);
}
return Send(request);
}
public async Task<HttpResponseMessage> 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<string> _lines = [];
public IReadOnlyList<string> 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>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (owner._lines)
{
owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception));
}
}
}
}
}