Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions backend/Api/Options/FrontendCorsOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace TrueMain.Options;

/// <summary>
/// Cross-origin policy for the public API, bound from <c>Cors:*</c>. The single
/// <see cref="Origins"/> list drives the <c>FrontendCors</c> policy: the browser
/// receives <c>Access-Control-Allow-Origin</c> only for the hosts listed here.
/// Named <c>FrontendCorsOptions</c> (not <c>CorsOptions</c>) to avoid colliding
/// with ASP.NET Core's own <c>Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions</c>.
/// </summary>
/// <remarks>
/// An empty list is a silent-failure trap — the policy still builds, but with no
/// allowed origins the frontend's cross-origin requests are rejected by the
/// browser even though the API itself answers. It reads as "works locally,
/// broken in prod" because Development ships real origins while
/// <c>appsettings.json</c> ships an empty array. Startup therefore validates the
/// list: empty fails the boot in every non-Development environment (see
/// <c>Program.cs</c>) and only logs a warning under Development.
/// </remarks>
public sealed class FrontendCorsOptions
{
Comment thread
ilyanfraimbault marked this conversation as resolved.
public const string SectionName = "Cors";

/// <summary>
/// Exact origins (scheme + host + port) allowed to make cross-origin browser
/// requests, e.g. <c>https://truemain.app</c>. Must be non-empty outside
/// Development.
/// </summary>
public string[] Origins { get; set; } = [];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON BLOQUANT — set peut être remplacé par init : les options sont liées une seule fois au démarrage et ne doivent pas être mutées ensuite. Le binder d'options .NET 6+ supporte init sans problème.

Suggested change
}
public string[] Origins { get; init; } = [];

54 changes: 43 additions & 11 deletions backend/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using TrueMain.Services.Champions;
using TrueMain.Services.Ops;
using TrueMain.Services.Truemains;
using AspNetCorsOptions = Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions;

var builder = WebApplication.CreateBuilder(args);
const string frontendCorsPolicy = "FrontendCors";
Expand Down Expand Up @@ -40,18 +41,37 @@
tags: ["ready"]);
}

var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [];
builder.Services.AddCors(options =>
{
options.AddPolicy(frontendCorsPolicy, policy =>
{
var builderPolicy = policy.AllowAnyHeader().AllowAnyMethod();
if (corsOrigins.Length > 0)
// CORS origins must be present outside Development: an empty list still builds a
// valid (but no-op) policy, so without this guard production silently ships a
// CORS policy that allows no cross-origin browser request — the frontend appears
// to work locally (Development ships real origins) but breaks in prod, where
// appsettings.json ships an empty array. Fail the boot when empty in any
// non-Development environment; only warn under Development (handled after build).
var isDevelopment = builder.Environment.IsDevelopment();
builder.Services.AddOptions<FrontendCorsOptions>()
.Bind(builder.Configuration.GetSection(FrontendCorsOptions.SectionName))
.Validate(
options => isDevelopment || options.Origins.Length > 0,
"Cors:Origins must contain at least one origin outside the Development environment; "
+ "an empty list ships a no-op CORS policy that silently rejects the frontend.")
.ValidateOnStart();
builder.Services.AddCors();
// Build the FrontendCors policy from the bound FrontendCorsOptions (single
// source — no separate config read) so the validated origins are the ones the
// policy uses.
builder.Services.AddOptions<AspNetCorsOptions>()
.Configure<IOptions<FrontendCorsOptions>>((corsPolicies, appCors) =>
corsPolicies.AddPolicy(frontendCorsPolicy, policy =>
{
builderPolicy.WithOrigins(corsOrigins);
}
});
});
var builderPolicy = policy.AllowAnyHeader().AllowAnyMethod();
Comment thread
ilyanfraimbault marked this conversation as resolved.
// Origins is guaranteed non-empty outside Development by
// ValidateOnStart; this guard only matters under Development, where an
// empty list is tolerated (and the policy then allows no origin).
if (appCors.Value.Origins.Length > 0)
Comment thread
ilyanfraimbault marked this conversation as resolved.
{
builderPolicy.WithOrigins(appCors.Value.Origins);
}
}));

builder.Services.AddOptions<MainAnalysisOptions>()
.Bind(builder.Configuration.GetSection("MainAnalysis"))
Expand Down Expand Up @@ -183,6 +203,18 @@
// the Ingestor's.
builder.Services.AddMongoLogging(builder.Configuration, processName: "Api");
var app = builder.Build();

// Non-Development boots already fail in ValidateOnStart when Origins is empty;
// this only fires under Development, where an empty list is tolerated but still
// worth flagging so a missing local override doesn't read as a working CORS setup.
if (app.Environment.IsDevelopment()
&& app.Services.GetRequiredService<IOptions<FrontendCorsOptions>>().Value.Origins.Length == 0)
{
Comment thread
ilyanfraimbault marked this conversation as resolved.
app.Logger.LogWarning(
"Cors:Origins is empty; the {Policy} policy allows no cross-origin browser request. Set Cors:Origins in configuration to let the frontend reach the API.",
frontendCorsPolicy);
}

await DatabaseMigrator.ApplyPendingMigrationsAsync(app.Services);

// Wrap unhandled exceptions in RFC 7807 ProblemDetails so clients
Expand Down
107 changes: 107 additions & 0 deletions backend/tests/TrueMain.IntegrationTests/CorsStartupIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Net;
using AwesomeAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace TrueMain.IntegrationTests;

/// <summary>
/// Covers the startup CORS guard added for issue #209: outside Development an
/// empty <c>Cors:Origins</c> must fail the boot loudly (it would otherwise ship a
/// no-op CORS policy that silently rejects the frontend in production), while a
/// populated list boots and actually allows the configured origin.
/// </summary>
[Collection(IntegrationCollection.Name)]
public sealed class CorsStartupIntegrationTests
{
private readonly PostgresFixture _fixture;

public CorsStartupIntegrationTests(PostgresFixture fixture)
{
_fixture = fixture;
}

[Fact]
public async Task Startup_InNonDevelopment_FailsWhenCorsOriginsEmpty()
{
// appsettings.json ships "Cors:Origins": [], so withholding the override
// leaves the list empty. Testing is non-Development, so ValidateOnStart
// must reject the boot rather than silently run with a no-op policy.
// (Testing, not Production: the readiness-health-check guard fails fast on
// a missing connection string only under Production, and the test host
// injects that string after Program reads it.)
await using var factory = new CorsStartupFactory(_fixture, environment: "Testing", origin: null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON BLOQUANT — startup.Should().Throw<OptionsValidationException>() invoque la lambda de façon synchrone. Si la stack d'hébergement venait à envelopper l'exception dans une AggregateException (comportement possible selon la version d'hôte), l'assertion échouerait avec un message peu lisible. En pratique ValidateOnStart propage OptionsValidationException directement dans la version actuelle de l'hôte générique, mais il pourrait être utile d'ajouter .WithInnerExceptionExactly<OptionsValidationException>() comme filet de sécurité si ce comportement venait à changer.

var startup = () => factory.CreateClient();

startup.Should().Throw<OptionsValidationException>(
"an empty Cors:Origins outside Development must fail the boot, not run a no-op policy")
.WithMessage("*Cors:Origins*");
}

[Fact]
Comment thread
ilyanfraimbault marked this conversation as resolved.
public async Task Startup_InNonDevelopment_AllowsConfiguredOriginWhenPresent()
{
const string allowedOrigin = "https://app.truemain.test";
await using var factory = new CorsStartupFactory(_fixture, environment: "Testing", origin: allowedOrigin);
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
BaseAddress = new Uri("https://localhost")
});

// A CORS preflight exercises the policy directly through the CORS
// middleware, so the assertion stays on the header that matters
// (Access-Control-Allow-Origin) without coupling to any endpoint's
// business logic or status code.
using var preflight = new HttpRequestMessage(HttpMethod.Options, "/champions");
preflight.Headers.Add("Origin", allowedOrigin);
preflight.Headers.Add("Access-Control-Request-Method", "GET");

var response = await client.SendAsync(preflight);
Comment thread
ilyanfraimbault marked this conversation as resolved.

response.StatusCode.Should().Be(HttpStatusCode.NoContent,
"a handled CORS preflight is short-circuited by the middleware with 204 No Content");
response.Headers.GetValues("Access-Control-Allow-Origin").Should().ContainSingle()
.Which.Should().Be(allowedOrigin,
"the configured origin must be echoed back so the browser accepts the cross-origin response");
}

/// <summary>
/// A <see cref="WebApplicationFactory{TEntryPoint}"/> that — unlike
/// <see cref="TrueMainWebApplicationFactory{TEntryPoint}"/> — does not inject a
/// default <c>Cors:Origins</c>, so a test can drive the host with the origin
/// list either empty (a null origin argument) or populated, and pick the
/// environment that decides whether the guard fails or warns.
/// </summary>
private sealed class CorsStartupFactory : WebApplicationFactory<Program>
{
private readonly string _environment;
private readonly List<KeyValuePair<string, string?>> _configuration;

public CorsStartupFactory(PostgresFixture fixture, string environment, string? origin)
{
_environment = environment;
_configuration =
[
new KeyValuePair<string, string?>("ConnectionStrings:TrueMain", fixture.ConnectionString),
new KeyValuePair<string, string?>(
"Ops:ApiKey",
TrueMainWebApplicationFactory<Program>.DefaultOpsApiKey)
];

if (origin is not null)
{
_configuration.Add(new KeyValuePair<string, string?>("Cors:Origins:0", origin));
}
}

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment(_environment);
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
configurationBuilder.AddInMemoryCollection(_configuration));
}
}
}
14 changes: 12 additions & 2 deletions backend/tests/TrueMain.TestKit/TrueMainWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ namespace TrueMain.TestKit;
/// against a <see cref="PostgresFixture"/>: sets the <c>Testing</c>
/// environment, injects <c>ConnectionStrings:TrueMain</c>, a test
/// <c>Ops:ApiKey</c> that satisfies the <c>[MinLength(32)]</c> validation,
/// plus any additional overrides the test wants to add.
/// a default <c>Cors:Origins</c> entry (Testing is non-Development, so the
/// startup CORS guard fails the boot when the list is empty), plus any
/// additional overrides the test wants to add.
/// </summary>
public class TrueMainWebApplicationFactory<TEntryPoint>(
PostgresFixture fixture,
Expand All @@ -24,6 +26,13 @@ public class TrueMainWebApplicationFactory<TEntryPoint>(
/// </summary>
public const string DefaultOpsApiKey = "test-kit-ops-key-0123456789-abcdefghijklmnop";

/// <summary>
/// A single allowed origin so the startup CORS guard (which fails the boot
/// outside Development when <c>Cors:Origins</c> is empty) is satisfied for
/// the <c>Testing</c> environment tests run under.
/// </summary>
public const string DefaultCorsOrigin = "https://frontend.test.truemain.local";

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
Expand All @@ -32,7 +41,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
var baseline = new List<KeyValuePair<string, string?>>
{
new("ConnectionStrings:TrueMain", fixture.ConnectionString),
new("Ops:ApiKey", DefaultOpsApiKey)
new("Ops:ApiKey", DefaultOpsApiKey),
new("Cors:Origins:0", DefaultCorsOrigin)
};

if (extraConfiguration is { Count: > 0 })
Expand Down