Merge hands/mill-votes-cut-4: public tally export with one-origin CORS

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 22:42:42 +02:00
commit a35119362a
6 changed files with 500 additions and 2 deletions

View file

@ -19,4 +19,9 @@ public sealed class ConceptVoteOptions
// /64 is bypassed by RFC 8981 privacy rotation and households can hold a /56 or /48; /56 is the practitioner
// default weighed in the note, not a standard. Tune on observed abuse.
public int IPv6PrefixLength { get; init; } = 56;
// 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.
public string[] AllowedOrigins { get; init; } = [];
}

View file

@ -0,0 +1,68 @@
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Bifrost.Web.Features.ConceptVotes;
// The public tally export and the one cross-origin policy in Bifrost. It owns nothing: counts are derived by
// ConceptAppraisalService, origins come from ConceptVoteOptions.
public static class ConceptTallyExport
{
public const string CorsPolicy = "ConceptTallies";
private const string RoutePrefix = "/public/concepts";
// GET only, no credentials, and required by the export route alone: every other route carries no CORS
// metadata and gets no CORS headers.
public static IServiceCollection AddConceptTallyCors(this IServiceCollection services)
{
services.AddCors();
services
.AddOptions<CorsOptions>()
.Configure<IOptions<ConceptVoteOptions>>((cors, conceptVotes) =>
cors.AddPolicy(
CorsPolicy,
policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET")));
return services;
}
// Replaces app.UseCors(): the one place the origin decision is made. Every response under the export route
// (200, 404, 304, preflight, refusal) varies by Origin, appended to any Vary already present, because the CORS
// headers differ by request Origin and a URL-keyed cache must not serve one variant to another. ASP.NET Core
// adds Vary: Origin itself only for policies of more than one origin; this policy has one, and a repeated token is harmless.
public static IApplicationBuilder UseConceptTallyCors(this IApplicationBuilder app)
{
app.Use((context, next) =>
{
if (context.Request.Path.StartsWithSegments(RoutePrefix))
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Append(HeaderNames.Vary, "Origin");
return Task.CompletedTask;
});
}
return next(context);
});
return app.UseCors();
}
// Counts only, per live subject: ConceptTally carries no voter key, account id or network tag.
public static IEndpointConventionBuilder MapConceptTallyExport(this IEndpointRouteBuilder app) =>
app.MapGet(RoutePrefix + "/{collection}/tallies", async (
string collection,
ConceptAppraisalService appraisals,
HttpResponse response,
CancellationToken cancellationToken) =>
{
var tallies = await appraisals.GetTalliesAsync(collection, cancellationToken);
if (tallies.Count == 0)
{
return Results.NotFound();
}
response.Headers.CacheControl = "public, max-age=60";
return Results.Json(tallies);
}).AllowAnonymous().RequireCors(CorsPolicy);
}

View file

@ -91,10 +91,23 @@ public sealed class StartupConfigurationValidator(
failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}.");
}
if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin))
{
failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash.");
}
if (failures.Count > 0)
{
throw new InvalidOperationException(
"Bifrost startup configuration is incomplete: " + string.Join(" ", failures));
}
}
// The authority form is the canonical spelling, so a wildcard, path, query, upper-case host or default port
// differs from it and is refused; user info is part of the authority, so it is refused on its own.
private static bool IsExactHttpsOrigin(string? origin) =>
Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
uri.Scheme == Uri.UriSchemeHttps &&
uri.UserInfo.Length == 0 &&
string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal);
}

View file

@ -205,6 +205,8 @@ builder.Services.AddAuthorization(options =>
});
});
builder.Services.AddConceptTallyCors();
var app = builder.Build();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
@ -228,6 +230,7 @@ app.UseHttpLogging();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseConceptTallyCors();
app.UseAuthentication();
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
app.UseAuthorization();
@ -244,6 +247,8 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke
statusCode: StatusCodes.Status503ServiceUnavailable);
}).AllowAnonymous();
app.MapConceptTallyExport();
app.MapPost("/github/webhooks", async (
HttpRequest request,
GitHubWebhookService gitHubWebhookService,

View file

@ -0,0 +1,383 @@
using System.Net;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
public sealed class ConceptTallyExportTests : IClassFixture<ConceptTallyExportTests.ExportFactory>
{
private const string AllowedOrigin = "https://aetheria.gamecult.org";
private const string Client = "203.0.113.7";
private readonly ExportFactory _factory;
public ConceptTallyExportTests(ExportFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Anonymous_request_gets_the_public_counts()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
await AddSubject(collection, "beta");
await AddSubject(collection, "untouched");
await Cast(collection, "alpha", DeviceVoter("dev-one"), ConceptStance.Up);
await Cast(collection, "alpha", DeviceVoter("dev-two"), ConceptStance.Up);
await Cast(collection, "alpha", DeviceVoter("dev-three"), ConceptStance.Down);
await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Active), ConceptStance.Up);
await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Authenticated), ConceptStance.Down);
await Cast(collection, "beta", DeviceVoter("dev-one"), ConceptStance.Down);
using var response = await Get(collection);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(["alpha", "beta", "untouched"], body.EnumerateObject().Select(p => p.Name).Order().ToArray());
AssertCounts(body.GetProperty("alpha"), up: 3, down: 2, memberUp: 1, memberDown: 0);
AssertCounts(body.GetProperty("beta"), up: 0, down: 1, memberUp: 0, memberDown: 0);
AssertCounts(body.GetProperty("untouched"), up: 0, down: 0, memberUp: 0, memberDown: 0);
}
[Fact]
public async Task Payload_carries_no_voter_identity()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var account = await AccountVoter(MembershipStatus.Authenticated);
var member = await AccountVoter(MembershipStatus.Active);
await Cast(collection, "alpha", DeviceVoter("canary-device-id-9c2e"), ConceptStance.Up);
await Cast(collection, "alpha", account, ConceptStance.Down);
await Cast(collection, "alpha", member, ConceptStance.Up);
using var response = await Get(collection);
var text = await response.Content.ReadAsStringAsync();
var alpha = JsonDocument.Parse(text).RootElement.GetProperty("alpha");
Assert.Equal(["down", "members", "up"], alpha.EnumerateObject().Select(p => p.Name).Order().ToArray());
Assert.Equal(["down", "up"], alpha.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray());
string[] tags;
using (var scope = _factory.Services.CreateScope())
{
tags = await scope.ServiceProvider.GetRequiredService<BifrostDbContext>()
.ConceptAppraisals.Select(x => x.NetworkTag).Distinct().ToArrayAsync();
}
Assert.All(tags, tag => Assert.NotEmpty(tag));
var forbidden = new List<string> { "canary-device-id-9c2e", "device:", "account:", "voterkey", "networktag", "useraccount" };
forbidden.AddRange(tags);
foreach (var voter in new[] { account, member })
{
forbidden.Add(voter.UserAccountId!.Value.ToString("N"));
forbidden.Add(voter.UserAccountId!.Value.ToString("D"));
}
foreach (var value in forbidden)
{
Assert.DoesNotContain(value, text, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public async Task Cors_allows_only_the_configured_origin_without_credentials()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var allowed = await Get(collection, AllowedOrigin);
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray());
Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials"));
using var otherHost = await Get(collection, "https://evil.example");
Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Origin"));
Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Credentials"));
using var preflightGet = await Preflight(collection, AllowedOrigin, "GET");
Assert.Equal([AllowedOrigin], preflightGet.Headers.GetValues("Access-Control-Allow-Origin").ToArray());
Assert.False(preflightGet.Headers.Contains("Access-Control-Allow-Credentials"));
using var preflightPost = await Preflight(collection, AllowedOrigin, "POST");
Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Origin"));
Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Methods"));
}
[Theory]
[InlineData("https://aetheria.gamecult.org.evil.example")]
[InlineData("https://aetheria.gamecult.orgx")]
[InlineData("https://evilaetheria.gamecult.org")]
[InlineData("https://www.aetheria.gamecult.org")]
[InlineData("HTTPS://AETHERIA.GAMECULT.ORG")]
[InlineData("https://Aetheria.gamecult.org")]
[InlineData("https://aetheria.gamecult.org/")]
[InlineData("https://aetheria.gamecult.org:8443")]
[InlineData("http://aetheria.gamecult.org")]
[InlineData("null")]
[InlineData("*")]
[InlineData("")]
public async Task Cors_refuses_origins_that_only_resemble_the_configured_one(string origin)
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var response = await Get(collection, origin);
using var preflight = await Preflight(collection, origin, "GET");
Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
Assert.False(preflight.Headers.Contains("Access-Control-Allow-Origin"));
}
[Fact]
public async Task Every_export_response_varies_by_origin()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var allowed = await Get(collection, AllowedOrigin);
using var lookalike = await Get(collection, "https://aetheria.gamecult.org.evil.example");
using var noOrigin = await Get(collection);
using var unknownAllowed = await Get(NewCollection(), AllowedOrigin);
using var unknownNoOrigin = await Get(NewCollection());
using var preflightAllowed = await Preflight(collection, AllowedOrigin, "GET");
using var preflightLookalike = await Preflight(collection, "https://evil.example", "GET");
using var preflightPost = await Preflight(collection, AllowedOrigin, "POST");
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
Assert.Equal(HttpStatusCode.OK, lookalike.StatusCode);
Assert.Equal(HttpStatusCode.OK, noOrigin.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, unknownAllowed.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, unknownNoOrigin.StatusCode);
foreach (var response in new[] { allowed, lookalike, noOrigin, unknownAllowed, unknownNoOrigin, preflightAllowed, preflightLookalike, preflightPost })
{
Assert.Contains("Origin", VaryTokens(response));
}
}
[Fact]
public async Task Origin_is_added_to_an_existing_vary()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies");
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.Add("X-Test-Vary", "Accept-Encoding");
request.Headers.TryAddWithoutValidation("Origin", AllowedOrigin);
using var response = await client.SendAsync(request);
Assert.Equal(["Accept-Encoding", "Origin"], VaryTokens(response).Order().ToArray());
}
private static string[] VaryTokens(HttpResponseMessage response) =>
response.Headers.Vary.ToArray();
[Fact]
public async Task Other_routes_carry_no_cors_headers()
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/healthz");
request.Headers.Add("Origin", AllowedOrigin);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
}
[Fact]
public async Task Retired_subject_leaves_the_export()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
await AddSubject(collection, "gone");
await Cast(collection, "gone", DeviceVoter("dev-one"), ConceptStance.Up);
await Retire(collection, "gone");
using var response = await Get(collection);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(["alpha"], body.EnumerateObject().Select(p => p.Name).ToArray());
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Single(db.ConceptAppraisals.Where(x => x.Subject!.Key == "gone" && x.Subject.Collection == collection));
}
[Fact]
public async Task Cache_control_is_public_sixty_seconds()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var response = await Get(collection);
var cache = response.Headers.CacheControl;
Assert.NotNull(cache);
Assert.True(cache!.Public);
Assert.Equal(TimeSpan.FromSeconds(60), cache.MaxAge);
}
[Fact]
public async Task Unknown_collection_is_not_found()
{
using var response = await Get(NewCollection());
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task The_export_only_reads()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
int rowsBefore, auditBefore;
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
rowsBefore = db.ConceptAppraisals.Count();
auditBefore = db.AuditEvents.Count();
}
using var response = await Get(collection);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Set-Cookie"));
using var after = _factory.Services.CreateScope();
var dbAfter = after.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Equal(rowsBefore, dbAfter.ConceptAppraisals.Count());
Assert.Equal(auditBefore, dbAfter.AuditEvents.Count());
}
private async Task<HttpResponseMessage> Get(string collection, string? origin = null)
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies");
request.Headers.Add("X-Test-Anonymous", "true");
if (origin is not null)
{
request.Headers.TryAddWithoutValidation("Origin", origin);
}
return await client.SendAsync(request);
}
private async Task<HttpResponseMessage> Preflight(string collection, string origin, string method)
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Options, $"/public/concepts/{collection}/tallies");
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.TryAddWithoutValidation("Origin", origin);
request.Headers.Add("Access-Control-Request-Method", method);
return await client.SendAsync(request);
}
private static void AssertCounts(JsonElement tally, int up, int down, int memberUp, int memberDown)
{
Assert.Equal(up, tally.GetProperty("up").GetInt32());
Assert.Equal(down, tally.GetProperty("down").GetInt32());
Assert.Equal(memberUp, tally.GetProperty("members").GetProperty("up").GetInt32());
Assert.Equal(memberDown, tally.GetProperty("members").GetProperty("down").GetInt32());
}
private static string NewCollection() => "c" + Guid.NewGuid().ToString("N");
private static VoterIdentity DeviceVoter(string deviceId) =>
VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!;
private async Task<VoterIdentity> AccountVoter(MembershipStatus status)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var now = DateTimeOffset.UtcNow;
var user = new UserAccount
{
DisplayName = "voter",
GitHubLogin = "voter",
NormalizedGitHubLogin = "voter",
CreatedAtUtc = now,
LastSeenAtUtc = now,
Membership = new Membership { Status = status, CreatedAtUtc = now }
};
user.Membership.UserAccountId = user.Id;
db.UserAccounts.Add(user);
await db.SaveChangesAsync();
return VoterIdentity.For(new CurrentBifrostActor(true, user), null)!;
}
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 async Task Cast(string collection, string key, VoterIdentity voter, ConceptStance stance)
{
using var scope = _factory.Services.CreateScope();
var result = await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
.CastAsync(collection, key, voter, stance, IPAddress.Parse(Client), default);
Assert.Equal(CastResult.Recorded, result);
}
// Stands in for any earlier middleware that already set a Vary: a request header names the value to preset.
private sealed class PriorVaryFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
if (context.Request.Headers.TryGetValue("X-Test-Vary", out var prior))
{
context.Response.Headers.Vary = prior;
}
return nextMiddleware(context);
});
next(app);
};
}
public sealed class ExportFactory : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services => services.AddTransient<IStartupFilter, PriorVaryFilter>());
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin,
["ConceptVotes:NetworkTagSecret"] = "export-test-tag-secret-0123456789-abcdef"
}));
}
}
}

View file

@ -185,6 +185,28 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0);
}
[Theory]
[InlineData("*")]
[InlineData("")]
[InlineData("null")]
[InlineData("https://canary-origin-5d1c.example/")]
[InlineData("https://canary-origin-5d1c.example/path")]
[InlineData("https://Canary-Origin-5d1c.example")]
[InlineData("http://canary-origin-5d1c.example")]
[InlineData("canary-origin-5d1c.example")]
[InlineData("https://canary-origin-5d1c.example:443")]
[InlineData("https://user@canary-origin-5d1c.example")]
public void Production_refuses_an_allowed_origin_that_is_not_an_exact_https_origin_without_echoing_it(string origin)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", origin]));
Assert.Contains("ConceptVotes:AllowedOrigins", refusal.Message);
Assert.DoesNotContain("canary-origin-5d1c", refusal.Message, StringComparison.OrdinalIgnoreCase);
Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", "https://other.example:8443"]);
Validate(keysPath: "/keys", allowedOrigins: []);
}
[Fact]
public void Development_does_not_require_keys_path_or_tag_secret()
{
@ -244,7 +266,8 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
string? connectionString = "Host=db",
string? tagSecret = ValidTagSecret,
int ipv4Length = 32,
int ipv6Length = 56)
int ipv6Length = 56,
string[]? allowedOrigins = null)
{
var values = new Dictionary<string, string?>
{
@ -271,7 +294,8 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
{
NetworkTagSecret = tagSecret ?? string.Empty,
IPv4PrefixLength = ipv4Length,
IPv6PrefixLength = ipv6Length
IPv6PrefixLength = ipv6Length,
AllowedOrigins = allowedOrigins ?? []
})).Validate();
}