From d13b808471866a16af83d7dc3a227ee772e2349a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:59:51 +0200 Subject: [PATCH 1/5] Public concept tally export with one-origin CORS GET /public/concepts/{collection}/tallies returns per-live-subject up, down and member counts only, anonymous, Cache-Control public max-age=60. CORS policy ConceptTallies is built from ConceptVotes:AllowedOrigins, GET only, no credentials, required on this route alone. Production startup refuses an allowed origin that is not an exact https origin. Not yet built or tested when committed. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 5 + .../Shared/StartupConfigurationValidator.cs | 12 + src/Bifrost.Web/Program.cs | 30 ++ .../ConceptTallyExportTests.cs | 318 ++++++++++++++++++ tests/Bifrost.Web.Tests/HostTrustTests.cs | 28 +- 5 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index 624832c..da13c4a 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -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; } = []; } diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index eef6d84..4d7cab9 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -91,10 +91,22 @@ 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, user info, upper-case host or + // default port differs from it and is refused. + private static bool IsExactHttpsOrigin(string? origin) => + Uri.TryCreate(origin, UriKind.Absolute, out var uri) && + uri.Scheme == Uri.UriSchemeHttps && + string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal); } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bec0271..a210489 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpLogging; @@ -205,6 +206,17 @@ builder.Services.AddAuthorization(options => }); }); +// The one cross-origin surface: the public tally export, read by browsers on the configured origins only. +// No credentials, GET only; every other route carries no CORS metadata and gets no CORS headers. +const string ConceptTalliesCorsPolicy = "ConceptTallies"; +builder.Services.AddCors(); +builder.Services + .AddOptions() + .Configure>((cors, conceptVotes) => + cors.AddPolicy( + ConceptTalliesCorsPolicy, + policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET"))); + var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -228,6 +240,7 @@ app.UseHttpLogging(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); +app.UseCors(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); @@ -244,6 +257,23 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke statusCode: StatusCodes.Status503ServiceUnavailable); }).AllowAnonymous(); +// Counts only, per live subject: ConceptTally carries no voter key, account id or network tag. +app.MapGet("/public/concepts/{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(ConceptTalliesCorsPolicy); + app.MapPost("/github/webhooks", async ( HttpRequest request, GitHubWebhookService gitHubWebhookService, diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs new file mode 100644 index 0000000..cd7b40c --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -0,0 +1,318 @@ +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.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptTallyExportTests : IClassFixture +{ + 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() + .ConceptAppraisals.Select(x => x.NetworkTag).Distinct().ToArrayAsync(); + } + + Assert.All(tags, tag => Assert.NotEmpty(tag)); + var forbidden = new List { "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 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(); + 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(); + 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(); + Assert.Equal(rowsBefore, dbAfter.ConceptAppraisals.Count()); + Assert.Equal(auditBefore, dbAfter.AuditEvents.Count()); + } + + private async Task 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 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 AccountVoter(MembershipStatus status) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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(); + 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 async Task Cast(string collection, string key, VoterIdentity voter, ConceptStance stance) + { + using var scope = _factory.Services.CreateScope(); + var result = await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, stance, IPAddress.Parse(Client), default); + Assert.Equal(CastResult.Recorded, result); + } + + public sealed class ExportFactory : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin, + ["ConceptVotes:NetworkTagSecret"] = "export-test-tag-secret-0123456789-abcdef" + })); + } + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 21ba350..7d55439 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -185,6 +185,28 @@ public sealed class HostTrustTests : IClassFixture 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( + () => 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 string? connectionString = "Host=db", string? tagSecret = ValidTagSecret, int ipv4Length = 32, - int ipv6Length = 56) + int ipv6Length = 56, + string[]? allowedOrigins = null) { var values = new Dictionary { @@ -271,7 +294,8 @@ public sealed class HostTrustTests : IClassFixture { NetworkTagSecret = tagSecret ?? string.Empty, IPv4PrefixLength = ipv4Length, - IPv6PrefixLength = ipv6Length + IPv6PrefixLength = ipv6Length, + AllowedOrigins = allowedOrigins ?? [] })).Validate(); } From cc00c64c9ee41f5710f408487890d34a5d175ca1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:07:47 +0200 Subject: [PATCH 2/5] Startup validation: an allowed origin with user info is refused Co-Authored-By: Claude Opus 5.5 --- .../Features/Shared/StartupConfigurationValidator.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 4d7cab9..c511413 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -103,10 +103,11 @@ public sealed class StartupConfigurationValidator( } } - // The authority form is the canonical spelling, so a wildcard, path, query, user info, upper-case host or - // default port differs from it and is refused. + // 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); } From 624cfd15675d090da97406a031df6a0fc48016a1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:20:13 +0200 Subject: [PATCH 3/5] Concept tally export and its CORS policy move out of Program.cs One owner for the route and the policy, so Stryker can mutate them: it flags every mutant of Program.cs as a compile error (safe mode), which left the export untested by mutation. Program.cs keeps UseCors and the two registration calls. Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptTallyExport.cs | 44 +++++++++++++++++++ src/Bifrost.Web/Program.cs | 29 +----------- 2 files changed, 46 insertions(+), 27 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs new file mode 100644 index 0000000..864bcab --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -0,0 +1,44 @@ +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Options; + +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"; + + // 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() + .Configure>((cors, conceptVotes) => + cors.AddPolicy( + CorsPolicy, + policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET"))); + return services; + } + + // Counts only, per live subject: ConceptTally carries no voter key, account id or network tag. + public static IEndpointConventionBuilder MapConceptTallyExport(this IEndpointRouteBuilder app) => + app.MapGet("/public/concepts/{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); +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index a210489..eac1bb0 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -17,7 +17,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpLogging; @@ -206,16 +205,7 @@ builder.Services.AddAuthorization(options => }); }); -// The one cross-origin surface: the public tally export, read by browsers on the configured origins only. -// No credentials, GET only; every other route carries no CORS metadata and gets no CORS headers. -const string ConceptTalliesCorsPolicy = "ConceptTallies"; -builder.Services.AddCors(); -builder.Services - .AddOptions() - .Configure>((cors, conceptVotes) => - cors.AddPolicy( - ConceptTalliesCorsPolicy, - policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET"))); +builder.Services.AddConceptTallyCors(); var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -257,22 +247,7 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke statusCode: StatusCodes.Status503ServiceUnavailable); }).AllowAnonymous(); -// Counts only, per live subject: ConceptTally carries no voter key, account id or network tag. -app.MapGet("/public/concepts/{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(ConceptTalliesCorsPolicy); +app.MapConceptTallyExport(); app.MapPost("/github/webhooks", async ( HttpRequest request, From 464da6bd507e74e32ad9b02cd652234b3d6a1ef3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 21:46:51 +0200 Subject: [PATCH 4/5] Tally export: every response under the route varies by Origin UseConceptTallyCors replaces UseCors and appends Vary: Origin to every response under the export route, preflights and 404s included, keeping any Vary already set. A single-origin ASP.NET policy never adds it itself. Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptTallyExport.cs | 33 +++++++++- src/Bifrost.Web/Program.cs | 2 +- .../ConceptTallyExportTests.cs | 65 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs index 864bcab..b5ac5ed 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -1,6 +1,7 @@ using Bifrost.Web.Configuration; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; namespace Bifrost.Web.Features.ConceptVotes; @@ -9,6 +10,7 @@ namespace Bifrost.Web.Features.ConceptVotes; 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. @@ -24,9 +26,38 @@ public static class ConceptTallyExport 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. + public static IApplicationBuilder UseConceptTallyCors(this IApplicationBuilder app) + { + app.Use((context, next) => + { + if (context.Request.Path.StartsWithSegments(RoutePrefix)) + { + context.Response.OnStarting(() => + { + var vary = context.Response.Headers.Vary; + var varies = vary.Any(v => v is not null + && v.Split(',').Any(t => t.Trim() is "*" || t.Trim().Equals("Origin", StringComparison.OrdinalIgnoreCase))); + if (!varies) + { + context.Response.Headers.Vary = StringValues.Concat(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("/public/concepts/{collection}/tallies", async ( + app.MapGet(RoutePrefix + "/{collection}/tallies", async ( string collection, ConceptAppraisalService appraisals, HttpResponse response, diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index eac1bb0..bea0beb 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -230,7 +230,7 @@ app.UseHttpLogging(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); -app.UseCors(); +app.UseConceptTallyCors(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs index cd7b40c..6109c1a 100644 --- a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -5,6 +5,7 @@ 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; @@ -138,6 +139,51 @@ public sealed class ConceptTallyExportTests : IClassFixture + response.Headers.Vary.ToArray(); + [Fact] public async Task Other_routes_carry_no_cors_headers() { @@ -302,11 +348,30 @@ public sealed class ConceptTallyExportTests : IClassFixture Configure(Action 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()); builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary { From dcc34255206a111864f4a296b77815ead967fa71 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:19:52 +0200 Subject: [PATCH 5/5] Tally export Vary: plain append, no dedupe The dedupe branch only added equivalent mutants; a repeated Origin token is harmless. Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/ConceptTallyExport.cs | 13 +++---------- tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs index b5ac5ed..fb1f87f 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -1,7 +1,7 @@ using Bifrost.Web.Configuration; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; namespace Bifrost.Web.Features.ConceptVotes; @@ -29,7 +29,7 @@ public static class ConceptTallyExport // 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. + // 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) => @@ -38,14 +38,7 @@ public static class ConceptTallyExport { context.Response.OnStarting(() => { - var vary = context.Response.Headers.Vary; - var varies = vary.Any(v => v is not null - && v.Split(',').Any(t => t.Trim() is "*" || t.Trim().Equals("Origin", StringComparison.OrdinalIgnoreCase))); - if (!varies) - { - context.Response.Headers.Vary = StringValues.Concat(vary, "Origin"); - } - + context.Response.Headers.Append(HeaderNames.Vary, "Origin"); return Task.CompletedTask; }); } diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs index 6109c1a..bd6a72f 100644 --- a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -166,7 +166,7 @@ public sealed class ConceptTallyExportTests : IClassFixture