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/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs new file mode 100644 index 0000000..fb1f87f --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -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() + .Configure>((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); +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index eef6d84..c511413 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -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); } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bec0271..bea0beb 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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(); 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, diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs new file mode 100644 index 0000000..bd6a72f --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -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 +{ + 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 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(); + 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); + } + + // 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 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 + { + ["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(); }