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 {