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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 21:46:51 +02:00
commit 464da6bd50
3 changed files with 98 additions and 2 deletions

View file

@ -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,

View file

@ -230,7 +230,7 @@ app.UseHttpLogging();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors();
app.UseConceptTallyCors();
app.UseAuthentication();
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
app.UseAuthorization();

View file

@ -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<ConceptTallyExportTe
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_and_not_repeated()
{
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()
{
@ -302,11 +348,30 @@ public sealed class ConceptTallyExportTests : IClassFixture<ConceptTallyExportTe
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?>
{