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:
parent
624cfd1567
commit
464da6bd50
3 changed files with 98 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ app.UseHttpLogging();
|
|||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseCors();
|
||||
app.UseConceptTallyCors();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
|
|
|||
|
|
@ -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?>
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue